mirror of
https://github.com/TecharoHQ/anubis.git
synced 2025-08-03 09:48:08 -04:00

* fix(lib): fix challenge issuance logic Fixes #869 v1.21.0 changed the core challenge flow to maintain information about challenges on the server side instead of only doing them via stateless idempotent generation functions and relying on details to not change. There was a subtle bug introduced in this change: if a client has an unknown challenge ID set in its test cookie, Anubis will clear that cookie and then throw an HTTP 500 error. This has been fixed by making Anubis throw a new challenge page instead. Signed-off-by: Xe Iaso <me@xeiaso.net> * test(lib): you win this time spell check Signed-off-by: Xe Iaso <me@xeiaso.net> --------- Signed-off-by: Xe Iaso <me@xeiaso.net>
556 lines
16 KiB
Go
556 lines
16 KiB
Go
package lib
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/cel-go/common/types"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
|
|
"github.com/TecharoHQ/anubis"
|
|
"github.com/TecharoHQ/anubis/decaymap"
|
|
"github.com/TecharoHQ/anubis/internal"
|
|
"github.com/TecharoHQ/anubis/internal/dnsbl"
|
|
"github.com/TecharoHQ/anubis/internal/ogtags"
|
|
"github.com/TecharoHQ/anubis/lib/challenge"
|
|
"github.com/TecharoHQ/anubis/lib/localization"
|
|
"github.com/TecharoHQ/anubis/lib/policy"
|
|
"github.com/TecharoHQ/anubis/lib/policy/checker"
|
|
"github.com/TecharoHQ/anubis/lib/policy/config"
|
|
"github.com/TecharoHQ/anubis/lib/store"
|
|
|
|
// challenge implementations
|
|
_ "github.com/TecharoHQ/anubis/lib/challenge/metarefresh"
|
|
_ "github.com/TecharoHQ/anubis/lib/challenge/proofofwork"
|
|
)
|
|
|
|
var (
|
|
challengesIssued = promauto.NewCounterVec(prometheus.CounterOpts{
|
|
Name: "anubis_challenges_issued",
|
|
Help: "The total number of challenges issued",
|
|
}, []string{"method"})
|
|
|
|
challengesValidated = promauto.NewCounterVec(prometheus.CounterOpts{
|
|
Name: "anubis_challenges_validated",
|
|
Help: "The total number of challenges validated",
|
|
}, []string{"method"})
|
|
|
|
droneBLHits = promauto.NewCounterVec(prometheus.CounterOpts{
|
|
Name: "anubis_dronebl_hits",
|
|
Help: "The total number of hits from DroneBL",
|
|
}, []string{"status"})
|
|
|
|
failedValidations = promauto.NewCounterVec(prometheus.CounterOpts{
|
|
Name: "anubis_failed_validations",
|
|
Help: "The total number of failed validations",
|
|
}, []string{"method"})
|
|
|
|
requestsProxied = promauto.NewCounterVec(prometheus.CounterOpts{
|
|
Name: "anubis_proxied_requests_total",
|
|
Help: "Number of requests proxied through Anubis to upstream targets",
|
|
}, []string{"host"})
|
|
)
|
|
|
|
type Server struct {
|
|
next http.Handler
|
|
mux *http.ServeMux
|
|
policy *policy.ParsedConfig
|
|
OGTags *ogtags.OGTagCache
|
|
ed25519Priv ed25519.PrivateKey
|
|
hs512Secret []byte
|
|
opts Options
|
|
store store.Interface
|
|
}
|
|
|
|
func (s *Server) getTokenKeyfunc() jwt.Keyfunc {
|
|
// return ED25519 key if HS512 is not set
|
|
if len(s.hs512Secret) == 0 {
|
|
return func(token *jwt.Token) (interface{}, error) {
|
|
return s.ed25519Priv.Public().(ed25519.PublicKey), nil
|
|
}
|
|
} else {
|
|
return func(token *jwt.Token) (interface{}, error) {
|
|
return s.hs512Secret, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) challengeFor(r *http.Request) (*challenge.Challenge, error) {
|
|
ckies := r.CookiesNamed(anubis.TestCookieName)
|
|
|
|
if len(ckies) == 0 {
|
|
return s.issueChallenge(r.Context(), r)
|
|
}
|
|
|
|
j := store.JSON[challenge.Challenge]{Underlying: s.store}
|
|
|
|
ckie := ckies[0]
|
|
chall, err := j.Get(r.Context(), "challenge:"+ckie.Value)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return s.issueChallenge(r.Context(), r)
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
return &chall, nil
|
|
}
|
|
|
|
func (s *Server) issueChallenge(ctx context.Context, r *http.Request) (*challenge.Challenge, error) {
|
|
id, err := uuid.NewV7()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var randomData = make([]byte, 256)
|
|
if _, err := rand.Read(randomData); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
chall := challenge.Challenge{
|
|
ID: id.String(),
|
|
RandomData: fmt.Sprintf("%x", randomData),
|
|
IssuedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"User-Agent": r.Header.Get("User-Agent"),
|
|
"X-Real-Ip": r.Header.Get("X-Real-Ip"),
|
|
},
|
|
}
|
|
|
|
j := store.JSON[challenge.Challenge]{Underlying: s.store}
|
|
if err := j.Set(ctx, "challenge:"+id.String(), chall, 30*time.Minute); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &chall, err
|
|
}
|
|
|
|
func (s *Server) maybeReverseProxyHttpStatusOnly(w http.ResponseWriter, r *http.Request) {
|
|
s.maybeReverseProxy(w, r, true)
|
|
}
|
|
|
|
func (s *Server) maybeReverseProxyOrPage(w http.ResponseWriter, r *http.Request) {
|
|
s.maybeReverseProxy(w, r, false)
|
|
}
|
|
|
|
func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) {
|
|
lg := internal.GetRequestLogger(r)
|
|
|
|
// Adjust cookie path if base prefix is not empty
|
|
cookiePath := "/"
|
|
if anubis.BasePrefix != "" {
|
|
cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
|
|
}
|
|
|
|
cr, rule, err := s.check(r)
|
|
if err != nil {
|
|
lg.Error("check failed", "err", err)
|
|
localizer := localization.GetLocalizer(r)
|
|
s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy\"", localizer.T("internal_server_error")))
|
|
return
|
|
}
|
|
|
|
r.Header.Add("X-Anubis-Rule", cr.Name)
|
|
r.Header.Add("X-Anubis-Action", string(cr.Rule))
|
|
lg = lg.With("check_result", cr)
|
|
policy.Applications.WithLabelValues(cr.Name, string(cr.Rule)).Add(1)
|
|
|
|
ip := r.Header.Get("X-Real-Ip")
|
|
|
|
if s.handleDNSBL(w, r, ip, lg) {
|
|
return
|
|
}
|
|
|
|
if s.checkRules(w, r, cr, lg, rule) {
|
|
return
|
|
}
|
|
|
|
ckie, err := r.Cookie(anubis.CookieName)
|
|
if err != nil {
|
|
lg.Debug("cookie not found", "path", r.URL.Path)
|
|
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
|
|
s.RenderIndex(w, r, rule, httpStatusOnly)
|
|
return
|
|
}
|
|
|
|
if err := ckie.Valid(); err != nil {
|
|
lg.Debug("cookie is invalid", "err", err)
|
|
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
|
|
s.RenderIndex(w, r, rule, httpStatusOnly)
|
|
return
|
|
}
|
|
|
|
if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
|
|
lg.Debug("cookie expired", "path", r.URL.Path)
|
|
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
|
|
s.RenderIndex(w, r, rule, httpStatusOnly)
|
|
return
|
|
}
|
|
|
|
token, err := jwt.ParseWithClaims(ckie.Value, jwt.MapClaims{}, s.getTokenKeyfunc(), jwt.WithExpirationRequired(), jwt.WithStrictDecoding())
|
|
|
|
if err != nil || !token.Valid {
|
|
lg.Debug("invalid token", "path", r.URL.Path, "err", err)
|
|
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
|
|
s.RenderIndex(w, r, rule, httpStatusOnly)
|
|
return
|
|
}
|
|
|
|
claims, ok := token.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
lg.Debug("invalid token claims type", "path", r.URL.Path)
|
|
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
|
|
s.RenderIndex(w, r, rule, httpStatusOnly)
|
|
return
|
|
}
|
|
|
|
policyRule, ok := claims["policyRule"].(string)
|
|
if !ok {
|
|
lg.Debug("policyRule claim is not a string")
|
|
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
|
|
s.RenderIndex(w, r, rule, httpStatusOnly)
|
|
return
|
|
}
|
|
|
|
if policyRule != rule.Hash() {
|
|
lg.Debug("user originally passed with a different rule, issuing new challenge", "old", policyRule, "new", rule.Name)
|
|
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
|
|
s.RenderIndex(w, r, rule, httpStatusOnly)
|
|
return
|
|
}
|
|
|
|
r.Header.Add("X-Anubis-Status", "PASS")
|
|
s.ServeHTTPNext(w, r)
|
|
}
|
|
|
|
func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, lg *slog.Logger, rule *policy.Bot) bool {
|
|
// Adjust cookie path if base prefix is not empty
|
|
cookiePath := "/"
|
|
if anubis.BasePrefix != "" {
|
|
cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
|
|
}
|
|
|
|
localizer := localization.GetLocalizer(r)
|
|
|
|
switch cr.Rule {
|
|
case config.RuleAllow:
|
|
lg.Debug("allowing traffic to origin (explicit)")
|
|
s.ServeHTTPNext(w, r)
|
|
return true
|
|
case config.RuleDeny:
|
|
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
|
|
lg.Info("explicit deny")
|
|
if rule == nil {
|
|
lg.Error("rule is nil, cannot calculate checksum")
|
|
s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy.RuleDeny\"", localizer.T("internal_server_error")))
|
|
return true
|
|
}
|
|
hash := rule.Hash()
|
|
|
|
lg.Debug("rule hash", "hash", hash)
|
|
s.respondWithStatus(w, r, fmt.Sprintf("%s %s", localizer.T("access_denied"), hash), s.policy.StatusCodes.Deny)
|
|
return true
|
|
case config.RuleChallenge:
|
|
lg.Debug("challenge requested")
|
|
case config.RuleBenchmark:
|
|
lg.Debug("serving benchmark page")
|
|
s.RenderBench(w, r)
|
|
return true
|
|
default:
|
|
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
|
|
slog.Error("CONFIG ERROR: unknown rule", "rule", cr.Rule)
|
|
s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy.Rules\"", localizer.T("internal_server_error")))
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Server) handleDNSBL(w http.ResponseWriter, r *http.Request, ip string, lg *slog.Logger) bool {
|
|
db := &store.JSON[dnsbl.DroneBLResponse]{Underlying: s.store, Prefix: "dronebl:"}
|
|
if s.policy.DNSBL && ip != "" {
|
|
resp, err := db.Get(r.Context(), ip)
|
|
if err != nil {
|
|
lg.Debug("looking up ip in dnsbl")
|
|
resp, err := dnsbl.Lookup(ip)
|
|
if err != nil {
|
|
lg.Error("can't look up ip in dnsbl", "err", err)
|
|
}
|
|
db.Set(r.Context(), ip, resp, 24*time.Hour)
|
|
droneBLHits.WithLabelValues(resp.String()).Inc()
|
|
}
|
|
|
|
if resp != dnsbl.AllGood {
|
|
lg.Info("DNSBL hit", "status", resp.String())
|
|
localizer := localization.GetLocalizer(r)
|
|
s.respondWithStatus(w, r, fmt.Sprintf("%s: %s, %s https://dronebl.org/lookup?ip=%s",
|
|
localizer.T("dronebl_entry"),
|
|
resp.String(),
|
|
localizer.T("see_dronebl_lookup"),
|
|
ip), s.policy.StatusCodes.Deny)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
|
|
lg := internal.GetRequestLogger(r)
|
|
localizer := localization.GetLocalizer(r)
|
|
|
|
redir := r.FormValue("redir")
|
|
if redir == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
encoder := json.NewEncoder(w)
|
|
lg.Error("invalid invocation of MakeChallenge", "redir", redir)
|
|
encoder.Encode(struct {
|
|
Error string `json:"error"`
|
|
}{
|
|
Error: localizer.T("invalid_invocation"),
|
|
})
|
|
return
|
|
}
|
|
|
|
r.URL.Path = redir
|
|
|
|
encoder := json.NewEncoder(w)
|
|
cr, rule, err := s.check(r)
|
|
if err != nil {
|
|
lg.Error("check failed", "err", err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
err := encoder.Encode(struct {
|
|
Error string `json:"error"`
|
|
}{
|
|
Error: fmt.Sprintf("%s \"makeChallenge\"", localizer.T("internal_server_error")),
|
|
})
|
|
if err != nil {
|
|
lg.Error("failed to encode error response", "err", err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
lg = lg.With("check_result", cr)
|
|
|
|
chall, err := s.challengeFor(r)
|
|
if err != nil {
|
|
lg.Error("failed to fetch or issue challenge", "err", err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
err := encoder.Encode(struct {
|
|
Error string `json:"error"`
|
|
}{
|
|
Error: fmt.Sprintf("%s \"makeChallenge\"", localizer.T("internal_server_error")),
|
|
})
|
|
if err != nil {
|
|
lg.Error("failed to encode error response", "err", err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
s.SetCookie(w, CookieOpts{Host: r.Host, Name: anubis.TestCookieName, Value: chall.ID})
|
|
|
|
err = encoder.Encode(struct {
|
|
Rules *config.ChallengeRules `json:"rules"`
|
|
Challenge string `json:"challenge"`
|
|
}{
|
|
Challenge: chall.RandomData,
|
|
Rules: rule.Challenge,
|
|
})
|
|
if err != nil {
|
|
lg.Error("failed to encode challenge", "err", err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
lg.Debug("made challenge", "challenge", chall, "rules", rule.Challenge, "cr", cr)
|
|
challengesIssued.WithLabelValues("api").Inc()
|
|
}
|
|
|
|
func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|
lg := internal.GetRequestLogger(r)
|
|
localizer := localization.GetLocalizer(r)
|
|
|
|
// Adjust cookie path if base prefix is not empty
|
|
cookiePath := "/"
|
|
if anubis.BasePrefix != "" {
|
|
cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
|
|
}
|
|
|
|
if _, err := r.Cookie(anubis.TestCookieName); errors.Is(err, http.ErrNoCookie) {
|
|
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
|
|
s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
|
|
lg.Warn("user has cookies disabled, this is not an anubis bug")
|
|
s.respondWithError(w, r, localizer.T("cookies_disabled"))
|
|
return
|
|
}
|
|
|
|
redir := r.FormValue("redir")
|
|
redirURL, err := url.ParseRequestURI(redir)
|
|
if err != nil {
|
|
lg.Error("invalid redirect", "err", err)
|
|
s.respondWithError(w, r, localizer.T("invalid_redirect"))
|
|
return
|
|
}
|
|
// used by the path checker rule
|
|
r.URL = redirURL
|
|
|
|
urlParsed, err := r.URL.Parse(redir)
|
|
if err != nil {
|
|
s.respondWithError(w, r, localizer.T("redirect_not_parseable"))
|
|
return
|
|
}
|
|
if (len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host)) || urlParsed.Host != r.URL.Host {
|
|
s.respondWithError(w, r, localizer.T("redirect_domain_not_allowed"))
|
|
return
|
|
}
|
|
|
|
cr, rule, err := s.check(r)
|
|
if err != nil {
|
|
lg.Error("check failed", "err", err)
|
|
s.respondWithError(w, r, fmt.Sprintf("%s \"passChallenge\"", localizer.T("internal_server_error")))
|
|
return
|
|
}
|
|
lg = lg.With("check_result", cr)
|
|
|
|
impl, ok := challenge.Get(rule.Challenge.Algorithm)
|
|
if !ok {
|
|
lg.Error("check failed", "err", err)
|
|
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
|
|
return
|
|
}
|
|
|
|
chall, err := s.challengeFor(r)
|
|
if err != nil {
|
|
lg.Error("check failed", "err", err)
|
|
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
|
|
return
|
|
}
|
|
|
|
in := &challenge.ValidateInput{
|
|
Challenge: chall,
|
|
Rule: rule,
|
|
Store: s.store,
|
|
}
|
|
|
|
if err := impl.Validate(r, lg, in); err != nil {
|
|
failedValidations.WithLabelValues(rule.Challenge.Algorithm).Inc()
|
|
var cerr *challenge.Error
|
|
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
|
|
lg.Debug("challenge validate call failed", "err", err)
|
|
|
|
switch {
|
|
case errors.As(err, &cerr):
|
|
switch {
|
|
case errors.Is(err, challenge.ErrFailed):
|
|
s.respondWithStatus(w, r, cerr.PublicReason, cerr.StatusCode)
|
|
case errors.Is(err, challenge.ErrInvalidFormat), errors.Is(err, challenge.ErrMissingField):
|
|
s.respondWithError(w, r, cerr.PublicReason)
|
|
}
|
|
}
|
|
}
|
|
|
|
// generate JWT cookie
|
|
tokenString, err := s.signJWT(jwt.MapClaims{
|
|
"challenge": chall.ID,
|
|
"method": rule.Challenge.Algorithm,
|
|
"policyRule": rule.Hash(),
|
|
"action": string(cr.Rule),
|
|
})
|
|
if err != nil {
|
|
lg.Error("failed to sign JWT", "err", err)
|
|
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
|
|
s.respondWithError(w, r, localizer.T("failed_to_sign_jwt"))
|
|
return
|
|
}
|
|
|
|
s.SetCookie(w, CookieOpts{Path: cookiePath, Host: r.Host, Value: tokenString})
|
|
|
|
challengesValidated.WithLabelValues(rule.Challenge.Algorithm).Inc()
|
|
lg.Debug("challenge passed, redirecting to app")
|
|
http.Redirect(w, r, redir, http.StatusFound)
|
|
}
|
|
|
|
func cr(name string, rule config.Rule, weight int) policy.CheckResult {
|
|
return policy.CheckResult{
|
|
Name: name,
|
|
Rule: rule,
|
|
Weight: weight,
|
|
}
|
|
}
|
|
|
|
// Check evaluates the list of rules, and returns the result
|
|
func (s *Server) check(r *http.Request) (policy.CheckResult, *policy.Bot, error) {
|
|
host := r.Header.Get("X-Real-Ip")
|
|
if host == "" {
|
|
return decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf("[misconfiguration] X-Real-Ip header is not set")
|
|
}
|
|
|
|
addr := net.ParseIP(host)
|
|
if addr == nil {
|
|
return decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf("[misconfiguration] %q is not an IP address", host)
|
|
}
|
|
|
|
weight := 0
|
|
|
|
for _, b := range s.policy.Bots {
|
|
match, err := b.Rules.Check(r)
|
|
if err != nil {
|
|
return decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf("can't run check %s: %w", b.Name, err)
|
|
}
|
|
|
|
if match {
|
|
switch b.Action {
|
|
case config.RuleDeny, config.RuleAllow, config.RuleBenchmark, config.RuleChallenge:
|
|
return cr("bot/"+b.Name, b.Action, weight), &b, nil
|
|
case config.RuleWeigh:
|
|
slog.Debug("adjusting weight", "name", b.Name, "delta", b.Weight.Adjust)
|
|
weight += b.Weight.Adjust
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, t := range s.policy.Thresholds {
|
|
result, _, err := t.Program.ContextEval(r.Context(), &policy.ThresholdRequest{Weight: weight})
|
|
if err != nil {
|
|
slog.Error("error when evaluating threshold expression", "expression", t.Expression.String(), "err", err)
|
|
continue
|
|
}
|
|
|
|
var matches bool
|
|
|
|
if val, ok := result.(types.Bool); ok {
|
|
matches = bool(val)
|
|
}
|
|
|
|
if matches {
|
|
return cr("threshold/"+t.Name, t.Action, weight), &policy.Bot{
|
|
Challenge: t.Challenge,
|
|
Rules: &checker.List{},
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
return cr("default/allow", config.RuleAllow, weight), &policy.Bot{
|
|
Challenge: &config.ChallengeRules{
|
|
Difficulty: s.policy.DefaultDifficulty,
|
|
ReportAs: s.policy.DefaultDifficulty,
|
|
Algorithm: config.DefaultAlgorithm,
|
|
},
|
|
Rules: &checker.List{},
|
|
}, nil
|
|
}
|