Compare commits

..

2 Commits

Author SHA1 Message Date
Xe Iaso f2c93d1105 chore: spelling
Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-05-08 18:42:37 -04:00
Xe Iaso b74bc6268c fix: patch GHSA-6wcg-mqvh-fcvg
PR https://github.com/TecharoHQ/anubis/pull/1015 added the ability for
reverse proxies using Anubis in subrequest auth mode to look at the path
of a request as there are many rules in the wild that rely on checking
the path. This is how access to things like robots.txt or anything in the
.well-known directory is unaffected by Anubis.

However this logic was also enabled for non-subrequest deployments of Anubis,
meaning that a specially crafted request could include a /.well-known/
path in it and then get around Anubis with little effort.

This fix gates the logic behind a new plumbed variable named subrequestMode
that only fires when Anubis is running in subrequest auth mode. This
properly contains that workaround so that the logic does not fire in
most deployments.

Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-05-08 18:39:22 -04:00
44 changed files with 625 additions and 1542 deletions
-8
View File
@@ -39,11 +39,3 @@ wenet
qwertiko
setuplistener
mba
xfu
xou
AWOO
firewalls
bindhosts
handrolled
xai
gitlab
-1
View File
@@ -27,7 +27,6 @@ jobs:
- palemoon/amd64
#- palemoon/i386
- robots_txt
- traefik
runs-on: ubuntu-latest
steps:
- name: Checkout code
+1
View File
@@ -10,3 +10,4 @@ builds:
ldflags:
- -s -w
- -extldflags "-static"
- -X github.com/TecharoHQ/anubis.Version={{.Env.VERSION}}
+3 -18
View File
@@ -1,27 +1,12 @@
// Package anubis contains the version number of Anubis.
package anubis
import (
"runtime/debug"
"time"
)
func init() {
bi, ok := debug.ReadBuildInfo()
if !ok {
return
}
// XXX(Xe): many things in this repo assume that the development version
// of anubis is `devel` and ReadBuildInfo returns `(devel)`. Shim the gap.
if bi.Main.Version != "(devel)" {
Version = bi.Main.Version
}
}
import "time"
// Version is the current version of Anubis.
//
// This is set from the Go module runtime version.
// This variable is set at build time using the -X linker flag. If not set,
// it defaults to "devel".
var Version = "devel"
// CookieName is the name of the cookie that Anubis uses in order to validate
-4
View File
@@ -36,7 +36,6 @@ import (
"github.com/TecharoHQ/anubis/lib/thoth"
"github.com/TecharoHQ/anubis/web"
"github.com/facebookgo/flagenv"
"github.com/google/uuid"
_ "github.com/joho/godotenv/autoload"
healthv1 "google.golang.org/grpc/health/grpc_health_v1"
)
@@ -194,9 +193,6 @@ func main() {
flagenv.Parse()
flag.Parse()
// Must be set before any concurrent UUID call.
uuid.EnableRandPool()
if *versionFlag {
fmt.Println("Anubis", anubis.Version)
return
-3
View File
@@ -41,9 +41,6 @@ bots:
# Challenge Firefox AI previews
- import: (data)/clients/x-firefox-ai.yaml
# x.ai has a scraper that is killing gitlab instances
- import: (data)/crawlers/xai.yaml
# Allow common "keeping the internet working" routes (well-known, favicon, robots.txt)
- import: (data)/common/keep-internet-working.yaml
-8
View File
@@ -1,8 +0,0 @@
- name: xai-crawler-and-asn
action: DENY
user_agent_regex: code-review-sourcing.*\+xai-research
remote_addresses:
- 69.12.56.0/21
- name: xai-crawler-user-agent
action: DENY
user_agent_regex: code-review-sourcing.*\+xai-research
-3
View File
@@ -25,9 +25,6 @@
# Challenge Firefox AI previews
- import: (data)/clients/x-firefox-ai.yaml
# x.ai has a scraper that is killing gitlab instances
- import: (data)/crawlers/xai.yaml
# Allow common "keeping the internet working" routes (well-known, favicon, robots.txt)
- import: (data)/common/keep-internet-working.yaml
+1 -17
View File
@@ -14,7 +14,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- This changes the project to: -->
- Patch [GHSA-6wcg-mqvh-fcvg](https://github.com/TecharoHQ/anubis/security/advisories/GHSA-6wcg-mqvh-fcvg) by containing subrequest logic to Anubis instances in subrequest mode.
- Implement robot9001 style delays on the honeypot feature so that the first hit takes 1 millisecond, the second takes 2, etc.
- Move metrics server configuration to [the policy file](./admin/policies.mdx#metrics-server).
- Expose [pprof endpoints](https://pkg.go.dev/net/http/pprof) on the metrics listener to enable profiling Anubis in production.
- fix: prevent nil pointer panic in challenge validation when threshold rules match during PassChallenge (#1463)
@@ -23,27 +22,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improve error messages and fix broken REDIRECT_DOMAINS link in docs ([#1193](https://github.com/TecharoHQ/anubis/issues/1193))
- Add Bulgarian locale ([#1394](https://github.com/TecharoHQ/anubis/pull/1394))
- Fixed case-sensitivity mismatch in geoipchecker.go
- Use [Go's native version stamping](https://michael.stapelberg.ch/posts/2026-04-05-stamp-it-all-programs-must-report-their-version/) instead of a handrolled variant.
- Fix CEL internal errors when iterating `headers`/`query` map wrappers by implementing map iterators for `HTTPHeaders` and `URLValues` ([#1465](https://github.com/TecharoHQ/anubis/pull/1465)).
- Enable [metrics serving via TLS](./admin/policies.mdx#tls), including [mutual TLS (mTLS)](./admin/policies.mdx#mtls).
- Enable [HTTP basic auth](./admin/policies.mdx#http-basic-authentication) for the metrics server.
- Fix a bug in the dataset poisoning maze that could allow denial of service [#1580](https://github.com/TecharoHQ/anubis/issues/1580).
- Add config option to add ASN to logs/metrics.
- Log weight when issuing challenge.
- Block x.ai's crawler for code review training.
- Gate pprof endpoints behind `metrics.debug` in the policy file.
- Limit naive honeypot r9k delay to one second.
- Fix an obscure case where adding query values to a subrequest match could cause an invalid rule match when using path based matching for protected resources.
- Fix an edge case where load average expression values could nil pointer dereference when Anubis just started up.
- Fix an obscure case where Anubis in subrequest mode could allow redirects to invalid domains with strange instructions.
- Fix `path_regex` and CEL `path` rules not matching when using Traefik `forwardAuth` middleware. Anubis now checks `X-Forwarded-Uri` (Traefik) in addition to `X-Original-URI` (nginx) when resolving the request path in subrequest mode ([#1628](https://github.com/TecharoHQ/anubis/issues/1628)).
- Validate bounds in the CEL `randInt` helper so non-positive or platform-overflowing arguments surface a typed CEL error instead of an evaluator panic.
- Fix a race in the bbolt store where the asynchronous cleanup scheduled by an expired read could delete a value that had just been refreshed; the delete now only fires when the key still carries the same expired generation it observed.
- Marginally increase the performances of requests processing
- Marginally improve the performances of PoW validation
- Marginally improve the performances of challenges generation/display
- Significantly improve the performances of the gzip middleware
- Significantly improve the performances of the PoW validation
- Log weight when issuing challenge
## v1.25.0: Necron
-18
View File
@@ -138,24 +138,6 @@ metrics:
socketMode: "0700" # must be a string
```
### Debug routes
Anubis' metrics server supports [pprof](https://pkg.go.dev/runtime/pprof), the Go standard library tool for profiling Go applications. This is very useful for debugging how Anubis works in the wild with regards to CPU, multicore, and RAM usage. pprof is very powerful and can expose command line arguments as part of the debugging setup (inside Google, everything is done with command line flags).
Prior versions of Anubis exposed pprof endpoints on all TCP bindhosts by default. This means that machines with incorrectly configured firewalls can expose command line arguments to the public internet in the right conditions.
In order to enable pprof profiling endpoints on the Metrics server, set the `debug` flag under the `metrics` block:
```yaml
metrics:
bind: ":9090"
network: "tcp"
debug: true
```
To err on the side of caution, this defaults to disabled. If this defaults migration breaks your configuration, please let us know in a ticket.
### TLS
If you want to serve the metrics server over TLS, use the `tls` block:
+1 -1
View File
@@ -106,7 +106,7 @@ require (
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-git/v5 v5.16.2 // indirect
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
+2 -2
View File
@@ -189,8 +189,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM=
github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ=
github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+5 -24
View File
@@ -2,28 +2,11 @@ package internal
import (
"compress/gzip"
"io"
"net/http"
"strings"
"sync"
)
func GzipMiddleware(level int, next http.Handler) http.Handler {
// Validate the level once at setup; gzip.NewWriterLevel only fails for
// invalid levels and we'd rather panic now than mid-request.
if _, err := gzip.NewWriterLevel(io.Discard, level); err != nil {
panic(err)
}
// Per-middleware pool of *gzip.Writer. Each entry carries ~40 KiB of
// deflate buffers; reusing them avoids that allocation on every request.
pool := sync.Pool{
New: func() any {
gz, _ := gzip.NewWriterLevel(io.Discard, level)
return gz
},
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
@@ -31,13 +14,11 @@ func GzipMiddleware(level int, next http.Handler) http.Handler {
}
w.Header().Set("Content-Encoding", "gzip")
gz := pool.Get().(*gzip.Writer)
gz.Reset(w)
defer func() {
gz.Close()
gz.Reset(io.Discard)
pool.Put(gz)
}()
gz, err := gzip.NewWriterLevel(w, level)
if err != nil {
panic(err)
}
defer gz.Close()
grw := gzipResponseWriter{ResponseWriter: w, sink: gz}
next.ServeHTTP(grw, r)
+3 -2
View File
@@ -11,8 +11,9 @@ import (
// SHA256sum computes a cryptographic hash. Still used for proof-of-work challenges
// where we need the security properties of a cryptographic hash function.
func SHA256sum(text string) string {
sum := sha256.Sum256([]byte(text))
return hex.EncodeToString(sum[:])
hash := sha256.New()
hash.Write([]byte(text))
return hex.EncodeToString(hash.Sum(nil))
}
// FastHash is a high-performance non-cryptographic hash function suitable for
-4
View File
@@ -5,7 +5,6 @@ import (
_ "embed"
"fmt"
"log/slog"
"math"
"math/rand/v2"
"net/http"
"net/netip"
@@ -169,9 +168,6 @@ func (i *Impl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
millisecondAmount := min(math.Pow(float64(networkCount), 2), 1000)
time.Sleep(time.Duration(millisecondAmount) * time.Millisecond)
spins := i.makeSpins()
affirmations := i.makeAffirmations()
title := i.makeTitle()
+5 -7
View File
@@ -4,7 +4,6 @@ import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -163,7 +162,6 @@ func (s *Server) issueChallenge(ctx context.Context, r *http.Request, lg *slog.L
if err != nil {
return nil, err
}
idStr := id.String()
var randomData = make([]byte, 64)
if _, err := rand.Read(randomData); err != nil {
@@ -171,9 +169,9 @@ func (s *Server) issueChallenge(ctx context.Context, r *http.Request, lg *slog.L
}
chall := challenge.Challenge{
ID: idStr,
ID: id.String(),
Method: rule.Challenge.Algorithm,
RandomData: hex.EncodeToString(randomData),
RandomData: fmt.Sprintf("%x", randomData),
IssuedAt: time.Now(),
Difficulty: rule.Challenge.Difficulty,
PolicyRuleHash: rule.Hash(),
@@ -184,11 +182,11 @@ func (s *Server) issueChallenge(ctx context.Context, r *http.Request, lg *slog.L
}
j := store.JSON[challenge.Challenge]{Underlying: s.store}
if err := j.Set(ctx, "challenge:"+idStr, chall, 30*time.Minute); err != nil {
if err := j.Set(ctx, "challenge:"+id.String(), chall, 30*time.Minute); err != nil {
return nil, err
}
lg.Info("new challenge issued", "challenge", idStr, "weight", cr.Weight)
lg.Info("new challenge issued", "challenge", id.String(), "weight", cr.Weight)
return &chall, err
}
@@ -242,7 +240,7 @@ func (s *Server) maybeReverseProxyOrPage(w http.ResponseWriter, r *http.Request)
func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) {
lg, r := s.getRequestLogger(r)
if val, _ := s.store.Get(r.Context(), "ogtags:allow:"+r.Host+r.URL.String()); val != nil {
if val, _ := s.store.Get(r.Context(), fmt.Sprintf("ogtags:allow:%s%s", r.Host, r.URL.String())); val != nil {
lg.Debug("serving opengraph tag asset")
s.ServeHTTPNext(w, r)
return
+6 -16
View File
@@ -1,15 +1,14 @@
package proofofwork
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/TecharoHQ/anubis/internal"
chall "github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/localization"
"github.com/a-h/templ"
@@ -46,7 +45,7 @@ func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInpu
return chall.NewError("validate", "invalid response", fmt.Errorf("%w nonce", chall.ErrMissingField))
}
_, err := strconv.Atoi(nonceStr)
nonce, err := strconv.Atoi(nonceStr)
if err != nil {
return chall.NewError("validate", "invalid response", fmt.Errorf("%w: nonce: %w", chall.ErrInvalidFormat, err))
@@ -67,20 +66,11 @@ func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInpu
return chall.NewError("validate", "invalid response", fmt.Errorf("%w response", chall.ErrMissingField))
}
// Stream the challenge and nonce into a single sha256 hasher to avoid
// the intermediate "challenge + nonceStr" concatenation. Hex-encode
// the digest into a stack buffer so the comparison runs without
// allocating a heap string.
h := sha256.New()
h.Write([]byte(challenge))
h.Write([]byte(nonceStr))
var sumBuf [sha256.Size]byte
sum := h.Sum(sumBuf[:0])
var hexBuf [sha256.Size * 2]byte
hex.Encode(hexBuf[:], sum)
calcString := fmt.Sprintf("%s%d", challenge, nonce)
calculated := internal.SHA256sum(calcString)
if subtle.ConstantTimeCompare([]byte(response), hexBuf[:]) != 1 {
return chall.NewError("validate", "invalid response", fmt.Errorf("%w: wanted response %s but got %s", chall.ErrFailed, string(hexBuf[:]), response))
if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
return chall.NewError("validate", "invalid response", fmt.Errorf("%w: wanted response %s but got %s", chall.ErrFailed, calculated, response))
}
// compare the leading zeroes
-1
View File
@@ -32,7 +32,6 @@ type Metrics struct {
Network string `json:"network" yaml:"network"`
SocketMode string `json:"socketMode" yaml:"socketMode"`
TLS *MetricsTLS `json:"tls" yaml:"tls"`
Debug bool `json:"debug" yaml:"debug"`
BasicAuth *MetricsBasicAuth `json:"basicAuth" yaml:"basicAuth"`
}
+7 -8
View File
@@ -403,15 +403,14 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
localizer := localization.GetLocalizer(r)
redir := r.FormValue("redir")
urlParsed, err := url.Parse(redir)
urlParsed, err := url.ParseRequestURI(redir)
if err != nil {
s.respondWithStatus(w, r, localizer.T("redirect_not_parseable"), makeCode(err), http.StatusBadRequest)
return
}
if urlParsed.Opaque != "" || (urlParsed.Scheme == "" && strings.HasPrefix(redir, "//")) {
s.respondWithStatus(w, r, localizer.T("invalid_redirect"), "", http.StatusBadRequest)
return
// if ParseRequestURI fails, try as relative URL
urlParsed, err = r.URL.Parse(redir)
if err != nil {
s.respondWithStatus(w, r, localizer.T("redirect_not_parseable"), makeCode(err), http.StatusBadRequest)
return
}
}
// validate URL scheme to prevent javascript:, data:, file:, tel:, etc.
-14
View File
@@ -223,17 +223,3 @@ func TestNoCacheOnError(t *testing.T) {
})
}
}
func TestRejectsHostlessRedirect(t *testing.T) {
pol := loadPolicies(t, "testdata/useragent.yaml", 0)
srv := spawnAnubis(t, Options{Policy: pol, RedirectDomains: []string{"allowed.example"}})
req := httptest.NewRequest(http.MethodGet, "https://anubis.example/.within.website/?redir=%2f%2fevil.example%2fphish", nil)
rr := httptest.NewRecorder()
srv.ServeHTTPNext(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected hostless redirect to be rejected, got HTTP %d body %q", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Location"); got != "" {
t.Fatalf("expected no Location header on rejected redirect, got %q", got)
}
}
+5 -9
View File
@@ -34,15 +34,11 @@ func (s *Server) Run(ctx context.Context, done func()) {
func (s *Server) run(ctx context.Context, lg *slog.Logger) error {
mux := http.NewServeMux()
if s.Config.Debug {
mux.HandleFunc("GET /debug/pprof/", pprof.Index)
mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
}
mux.HandleFunc("GET /debug/pprof/", pprof.Index)
mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
mux.Handle("/metrics", promhttp.Handler())
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
st, ok := internal.GetHealth("anubis")
-49
View File
@@ -1,49 +0,0 @@
package metrics
import (
"context"
"io"
"log/slog"
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/TecharoHQ/anubis/lib/config"
)
func TestMetricsPprofCmdlineExposedWithoutAuthentication(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln.Addr().String()
_ = ln.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
srv := &Server{
Config: &config.Metrics{Network: "tcp", Bind: addr},
Log: slog.Default(),
}
go srv.Run(ctx, func() { close(done) })
url := "http://" + addr + "/debug/pprof/cmdline"
var body []byte
resp, err := http.Get(url)
if err == nil {
body, err = io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("can't read body: %v", err)
}
defer resp.Body.Close()
}
time.Sleep(50 * time.Millisecond)
if strings.Contains(string(body), "metrics.test") {
t.Fatalf("pprof is enabled by default, cmdline process arguments: %q", string(body))
}
cancel()
<-done
}
+4 -15
View File
@@ -1,6 +1,8 @@
package policy
import (
"fmt"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/checker"
@@ -11,22 +13,9 @@ type Bot struct {
Challenge *config.ChallengeRules
Weight *config.Weight
Name string
// hash caches the result of Hash() when populated at parse time, see ParseConfig
hash string
Action config.Rule
Action config.Rule
}
// Hash returns a stable identifier for this Bot derived from its Name
// and Rules. When the cached value is present (populated by
// ParseConfig) it is returned directly; otherwise the hash is
// recomputed on demand so callers do not have to know about the cache.
func (b Bot) Hash() string {
if b.hash != "" {
return b.hash
}
var rulesHash string
if b.Rules != nil { // defensive, should never happen
rulesHash = b.Rules.Hash()
}
return internal.FastHash(b.Name + "::" + rulesHash)
return internal.FastHash(fmt.Sprintf("%s::%s", b.Name, b.Rules.Hash()))
}
+6 -17
View File
@@ -13,12 +13,11 @@ import (
)
type CELChecker struct {
program cel.Program
src string
subRequestMode bool
program cel.Program
src string
}
func NewCELChecker(cfg *config.ExpressionOrList, dnsObj *dns.Dns, subRequestMode bool) (*CELChecker, error) {
func NewCELChecker(cfg *config.ExpressionOrList, dnsObj *dns.Dns) (*CELChecker, error) {
env, err := expressions.BotEnvironment(dnsObj)
if err != nil {
return nil, err
@@ -30,9 +29,8 @@ func NewCELChecker(cfg *config.ExpressionOrList, dnsObj *dns.Dns, subRequestMode
}
return &CELChecker{
src: cfg.String(),
program: program,
subRequestMode: subRequestMode,
src: cfg.String(),
program: program,
}, nil
}
@@ -41,7 +39,7 @@ func (cc *CELChecker) Hash() string {
}
func (cc *CELChecker) Check(r *http.Request) (bool, error) {
result, _, err := cc.program.ContextEval(r.Context(), &CELRequest{r, cc.subRequestMode})
result, _, err := cc.program.ContextEval(r.Context(), &CELRequest{r})
if err != nil {
return false, err
@@ -56,7 +54,6 @@ func (cc *CELChecker) Check(r *http.Request) (bool, error) {
type CELRequest struct {
*http.Request
subRequestMode bool
}
func (cr *CELRequest) Parent() cel.Activation { return nil }
@@ -74,14 +71,6 @@ func (cr *CELRequest) ResolveName(name string) (any, bool) {
case "userAgent":
return cr.UserAgent(), true
case "path":
if cr.subRequestMode {
if xou := cr.Header.Get("X-Original-URI"); xou != "" {
return xou, true
}
if xfu := cr.Header.Get("X-Forwarded-Uri"); xfu != "" {
return xfu, true
}
}
return cr.URL.Path, true
case "query":
return expressions.URLValues{Values: cr.URL.Query()}, true
+1 -75
View File
@@ -23,7 +23,7 @@ func TestCELChecker_MapIterationWrappers(t *testing.T) {
Expression: `headers.exists(k, k == "Accept") && query.exists(k, k == "format")`,
}
checker, err := NewCELChecker(cfg, newTestDNS(t), false)
checker, err := NewCELChecker(cfg, newTestDNS(t))
if err != nil {
t.Fatalf("creating CEL checker failed: %v", err)
}
@@ -42,77 +42,3 @@ func TestCELChecker_MapIterationWrappers(t *testing.T) {
t.Fatal("expected expression to evaluate true")
}
}
func TestCELChecker_PathWithForwardedUri(t *testing.T) {
tests := []struct {
name string
expression string
xForwardedUri string
urlPath string
subRequestMode bool
want bool
}{
{
name: "path matches X-Forwarded-Uri in subrequest mode",
expression: `path.startsWith("/admin")`,
xForwardedUri: "/admin/secret",
urlPath: "/.within.website/x/cmd/anubis/api/check",
subRequestMode: true,
want: true,
},
{
name: "path with query string",
expression: `path.startsWith("/api/secret")`,
xForwardedUri: "/api/secret?token=abc",
urlPath: "/.within.website/x/cmd/anubis/api/check",
subRequestMode: true,
want: true,
},
{
name: "path falls back to url path when no header",
expression: `path == "/public/page"`,
urlPath: "/public/page",
subRequestMode: true,
want: true,
},
{
name: "non-subrequest mode ignores X-Forwarded-Uri",
expression: `path.startsWith("/admin")`,
xForwardedUri: "/admin/secret",
urlPath: "/public/page",
subRequestMode: false,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.ExpressionOrList{
Expression: tt.expression,
}
checker, err := NewCELChecker(cfg, newTestDNS(t), tt.subRequestMode)
if err != nil {
t.Fatalf("NewCELChecker() error: %v", err)
}
req, err := http.NewRequest(http.MethodGet, "http://example.com"+tt.urlPath, nil)
if err != nil {
t.Fatalf("http.NewRequest: %v", err)
}
if tt.xForwardedUri != "" {
req.Header.Set("X-Forwarded-Uri", tt.xForwardedUri)
}
got, err := checker.Check(req)
if err != nil {
t.Fatalf("Check() error: %v", err)
}
if got != tt.want {
t.Errorf("Check() = %v, want %v (subRequestMode=%v, urlPath=%q, X-Forwarded-Uri=%q)",
got, tt.want, tt.subRequestMode, tt.urlPath, tt.xForwardedUri)
}
})
}
}
-7
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"net/netip"
"net/url"
"regexp"
"strings"
@@ -111,13 +110,7 @@ func NewPathChecker(rexStr string, subrequestMode bool) (checker.Impl, error) {
func (pc *PathChecker) Check(r *http.Request) (bool, error) {
if pc.subRequestMode {
originalUrl := r.Header.Get("X-Original-URI")
if originalUrl == "" {
originalUrl = r.Header.Get("X-Forwarded-Uri")
}
if originalUrl != "" {
if parsed, err := url.ParseRequestURI(originalUrl); err == nil {
originalUrl = parsed.Path
}
if pc.regexp.MatchString(originalUrl) {
return true, nil
}
-116
View File
@@ -410,119 +410,3 @@ func TestPathChecker_GHSA_6wcg_mqvh_fcvg(t *testing.T) {
})
}
}
func TestPathChecker_XForwardedUri(t *testing.T) {
tests := []struct {
name string
regex string
xForwardedUri string
xOriginalURI string
urlPath string
subRequestMode bool
want bool
}{
{
name: "X-Forwarded-Uri matches regex in subrequest mode",
regex: "^/admin/.*",
xForwardedUri: "/admin/users",
urlPath: "/.within.website/x/cmd/anubis/api/check",
subRequestMode: true,
want: true,
},
{
name: "X-Forwarded-Uri with query string",
regex: "^/admin/.*",
xForwardedUri: "/admin/users?page=1",
urlPath: "/.within.website/x/cmd/anubis/api/check",
subRequestMode: true,
want: true,
},
{
name: "X-Original-URI takes priority over X-Forwarded-Uri",
regex: "^/admin/.*",
xForwardedUri: "/public/page",
xOriginalURI: "/admin/users",
urlPath: "/.within.website/x/cmd/anubis/api/check",
subRequestMode: true,
want: true,
},
{
name: "falls back to X-Forwarded-Uri when no X-Original-URI",
regex: "^/admin/.*",
xForwardedUri: "/admin/dashboard",
urlPath: "/.within.website/x/cmd/anubis/api/check",
subRequestMode: true,
want: true,
},
{
name: "neither header matches, url path matches",
regex: "^/public/.*",
xForwardedUri: "/admin/users",
urlPath: "/public/page",
subRequestMode: true,
want: true,
},
{
name: "nothing matches",
regex: "^/admin/.*",
xForwardedUri: "/public/page",
urlPath: "/.within.website/x/cmd/anubis/api/check",
subRequestMode: true,
want: false,
},
{
name: "non-subrequest mode ignores X-Forwarded-Uri",
regex: "^/admin/.*",
xForwardedUri: "/admin/users",
urlPath: "/public/page",
subRequestMode: false,
want: false,
},
{
name: "non-subrequest mode uses url path",
regex: "^/admin/.*",
xForwardedUri: "/public/page",
urlPath: "/admin/secret",
subRequestMode: false,
want: true,
},
{
name: "empty X-Forwarded-Uri falls back to url path",
regex: "^/check$",
urlPath: "/check",
subRequestMode: true,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pc, err := NewPathChecker(tt.regex, tt.subRequestMode)
if err != nil {
t.Fatalf("NewPathChecker(%q, %v) returned error: %v", tt.regex, tt.subRequestMode, err)
}
req, err := http.NewRequest(http.MethodGet, "http://example.com"+tt.urlPath, nil)
if err != nil {
t.Fatalf("http.NewRequest: %v", err)
}
if tt.xForwardedUri != "" {
req.Header.Set("X-Forwarded-Uri", tt.xForwardedUri)
}
if tt.xOriginalURI != "" {
req.Header.Set("X-Original-URI", tt.xOriginalURI)
}
got, err := pc.Check(req)
if err != nil {
t.Fatalf("Check() unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("Check() = %v, want %v (subRequestMode=%v, urlPath=%q, X-Forwarded-Uri=%q, X-Original-URI=%q)",
got, tt.want, tt.subRequestMode, tt.urlPath, tt.xForwardedUri, tt.xOriginalURI)
}
})
}
}
+1 -10
View File
@@ -222,16 +222,7 @@ func New(opts ...cel.EnvOption) (*cel.Env, error) {
return types.ValOrErr(val, "value is not an integer, but is %T", val)
}
if n <= 0 {
return types.NewErr("randInt bound must be positive, got %d", int64(n))
}
bound := int(n)
if types.Int(bound) != n {
return types.NewErr("randInt bound %d overflows platform int", int64(n))
}
return types.Int(rand.IntN(bound))
return types.Int(rand.IntN(int(n)))
}),
),
),
@@ -9,7 +9,6 @@ import (
"github.com/TecharoHQ/anubis/internal/dns"
"github.com/TecharoHQ/anubis/lib/store/memory"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
)
@@ -689,14 +688,6 @@ func TestNewEnvironment(t *testing.T) {
description: "should return values in correct range",
shouldCompile: true,
},
{
name: "randInt-large-bound",
expression: `randInt(2147483647) >= 0`,
variables: map[string]any{},
expectBool: boolPtr(true),
description: "should accept int32-max bounds without overflow",
shouldCompile: true,
},
{
name: "strings-extension-size",
expression: `"hello".size() == 5`,
@@ -759,65 +750,3 @@ func TestNewEnvironment(t *testing.T) {
func boolPtr(b bool) *bool {
return &b
}
func TestRandIntInvalidBounds(t *testing.T) {
env, err := New(cel.Variable("contentLength", cel.IntType))
if err != nil {
t.Fatalf("failed to create environment: %v", err)
}
tests := []struct {
name string
expression string
variables map[string]any
wantErrText string
description string
}{
{
name: "zero-bound-literal",
expression: `randInt(0)`,
variables: map[string]any{},
wantErrText: "randInt bound must be positive",
description: "randInt(0) should return a CEL error, not panic",
},
{
name: "negative-bound-literal",
expression: `randInt(-5)`,
variables: map[string]any{},
wantErrText: "randInt bound must be positive",
description: "randInt(-5) should return a CEL error, not panic",
},
{
name: "zero-bound-from-variable",
expression: `randInt(contentLength)`,
variables: map[string]any{"contentLength": 0},
wantErrText: "randInt bound must be positive",
description: "attacker-controlled zero contentLength should error gracefully",
},
{
name: "negative-bound-from-variable",
expression: `randInt(contentLength)`,
variables: map[string]any{"contentLength": -1},
wantErrText: "randInt bound must be positive",
description: "attacker-controlled negative contentLength should error gracefully",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prog, err := Compile(env, tt.expression)
if err != nil {
t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
}
result, _, err := prog.Eval(tt.variables)
if err == nil {
t.Fatalf("%s: expected an evaluation error, got result %v", tt.description, result)
}
if !strings.Contains(err.Error(), tt.wantErrText) {
t.Errorf("%s: expected error containing %q, got %q", tt.description, tt.wantErrText, err.Error())
}
})
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ var (
)
func init() {
globalLoadAvg = &loadAvg{data: &load.AvgStat{}}
globalLoadAvg = &loadAvg{}
go globalLoadAvg.updateThread(context.Background())
}
+1 -2
View File
@@ -170,7 +170,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
}
if b.Expression != nil {
c, err := NewCELChecker(b.Expression, result.Dns, subrequestMode)
c, err := NewCELChecker(b.Expression, result.Dns)
if err != nil {
validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s expressions: %w", b.Name, err))
} else {
@@ -219,7 +219,6 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
result.Impressum = c.Impressum
parsedBot.Rules = cl
parsedBot.hash = parsedBot.Hash()
result.Bots = append(result.Bots, parsedBot)
}
-26
View File
@@ -1,8 +1,6 @@
package policy
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
@@ -87,27 +85,3 @@ func TestBadConfigs(t *testing.T) {
})
}
}
func TestPathCheckerStripsForwardedURIQuery(t *testing.T) {
checker, err := NewPathChecker("^/admin$", true)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "https://anubis.local/.within.website/x/cmd/anubis/api/check", nil)
req.Header.Set("X-Forwarded-Uri", "/admin?x=1")
matched, err := checker.Check(req)
if err != nil {
t.Fatal(err)
}
if !matched {
t.Fatalf("expected exact path checker to match forwarded URI when query string is appended")
}
req.Header.Set("X-Forwarded-Uri", "/admin")
matched, err = checker.Check(req)
if err != nil {
t.Fatal(err)
}
if !matched {
t.Fatalf("expected exact path checker to match forwarded URI without query string")
}
}
+1 -28
View File
@@ -50,33 +50,6 @@ func (s *Store) Delete(ctx context.Context, key string) error {
})
}
// deleteIfExpired removes key only if it still carries the exact expiry that an
// expired Get observed and that expiry is still in the past.
//
// Get runs in a read-only transaction, so it can only schedule cleanup
// asynchronously. Between observing the expiry and this delete running, another
// request may Set a fresh value for the same key. Re-reading and matching the
// observed expiry inside the write transaction makes the timestamp act as a
// generation token: a refreshed value carries a different, future expiry and is
// therefore left untouched (see AWOO-015).
func (s *Store) deleteIfExpired(ctx context.Context, key string, observed time.Time) error {
return s.bdb.Update(func(tx *bbolt.Tx) error {
valueBkt := tx.Bucket([]byte(key))
if valueBkt == nil {
return nil
}
expiry, err := time.Parse(time.RFC3339Nano, string(valueBkt.Get([]byte("expiry"))))
if err != nil || !expiry.Equal(observed) || !time.Now().After(expiry) {
// Unparseable, refreshed to a different generation, or no longer
// expired: leave it for cleanup or a later Get to handle.
return nil
}
return tx.DeleteBucket([]byte(key))
})
}
// Get a value from the datastore.
//
// Because each value is stored in its own bucket with data and expiry keys,
@@ -104,7 +77,7 @@ func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
}
if time.Now().After(expiry) {
go s.deleteIfExpired(context.Background(), key, expiry)
go s.Delete(context.Background(), key)
return fmt.Errorf("%w: %q", store.ErrNotFound, key)
}
-153
View File
@@ -4,10 +4,8 @@ import (
"encoding/json"
"path/filepath"
"testing"
"time"
"github.com/TecharoHQ/anubis/lib/store/storetest"
"go.etcd.io/bbolt"
)
func TestImpl(t *testing.T) {
@@ -22,154 +20,3 @@ func TestImpl(t *testing.T) {
storetest.Common(t, Factory{}, json.RawMessage(data))
}
// newTestStore returns a Store backed by a throwaway bbolt database that is
// closed when the test finishes.
func newTestStore(t *testing.T) *Store {
t.Helper()
db, err := bbolt.Open(filepath.Join(t.TempDir(), "db"), 0600, nil)
if err != nil {
t.Fatalf("can't open bbolt database: %v", err)
}
t.Cleanup(func() { db.Close() })
return &Store{bdb: db}
}
// mustSet writes a value with the given relative expiry, failing the test on error.
func mustSet(t *testing.T, s *Store, key, value string, expiry time.Duration) {
t.Helper()
if err := s.Set(t.Context(), key, []byte(value), expiry); err != nil {
t.Fatalf("Set(%q): %v", key, err)
}
}
// readExpiry returns the expiry timestamp currently stored for key, as a Get
// would parse it. It fails the test if the bucket or expiry is missing.
func readExpiry(t *testing.T, s *Store, key string) time.Time {
t.Helper()
var out time.Time
if err := s.bdb.View(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte(key))
if b == nil {
t.Fatalf("bucket %q missing", key)
}
expiry, err := time.Parse(time.RFC3339Nano, string(b.Get([]byte("expiry"))))
if err != nil {
return err
}
out = expiry
return nil
}); err != nil {
t.Fatalf("reading expiry for %q: %v", key, err)
}
return out
}
// rawData reads the raw data value for key directly, bypassing the expiry check
// in Get so tests can observe whether a bucket physically exists. It returns nil
// when the bucket is absent.
func rawData(t *testing.T, s *Store, key string) []byte {
t.Helper()
var out []byte
if err := s.bdb.View(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte(key))
if b == nil {
return nil
}
data := b.Get([]byte("data"))
out = make([]byte, len(data))
copy(out, data)
return nil
}); err != nil {
t.Fatalf("reading data for %q: %v", key, err)
}
return out
}
// TestDeleteIfExpired guards against AWOO-015: a stale async delete scheduled by
// an expired Get must not erase a value that was refreshed (or otherwise differs
// from) the generation it observed.
func TestDeleteIfExpired(t *testing.T) {
const key = "challenge"
for _, tt := range []struct {
setup func(t *testing.T, s *Store) time.Time
name string
wantValue string
wantPresent bool
}{
{
name: "deletes the observed expired generation",
setup: func(t *testing.T, s *Store) time.Time {
mustSet(t, s, key, "old", -time.Minute)
return readExpiry(t, s, key)
},
wantPresent: false,
},
{
name: "preserves a refreshed generation",
setup: func(t *testing.T, s *Store) time.Time {
mustSet(t, s, key, "old", -time.Minute)
observed := readExpiry(t, s, key)
mustSet(t, s, key, "fresh", time.Hour)
return observed
},
wantPresent: true,
wantValue: "fresh",
},
{
name: "skips on generation mismatch",
setup: func(t *testing.T, s *Store) time.Time {
mustSet(t, s, key, "old", -time.Minute)
// An expiry we never wrote: even though the stored value is
// expired, it is a different generation and must be left alone.
return time.Now().Add(-2 * time.Hour)
},
wantPresent: true,
wantValue: "old",
},
{
name: "skips a non-expired observation",
setup: func(t *testing.T, s *Store) time.Time {
mustSet(t, s, key, "live", time.Hour)
return readExpiry(t, s, key)
},
wantPresent: true,
wantValue: "live",
},
{
name: "no-op when bucket is absent",
setup: func(t *testing.T, s *Store) time.Time {
return time.Now().Add(-time.Hour)
},
wantPresent: false,
},
} {
t.Run(tt.name, func(t *testing.T) {
s := newTestStore(t)
observed := tt.setup(t, s)
if err := s.deleteIfExpired(t.Context(), key, observed); err != nil {
t.Fatalf("deleteIfExpired(%q): %v", key, err)
}
got := rawData(t, s, key)
switch {
case tt.wantPresent && got == nil:
t.Fatalf("key %q: want present with value %q, got deleted", key, tt.wantValue)
case tt.wantPresent && string(got) != tt.wantValue:
t.Errorf("key %q: want value %q, got %q", key, tt.wantValue, string(got))
case !tt.wantPresent && got != nil:
t.Errorf("key %q: want deleted, got value %q", key, string(got))
}
})
}
}
+561 -656
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -20,23 +20,23 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"baseline-browser-mapping": "^2.10.33",
"cssnano": "^8.0.1",
"cssnano-preset-advanced": "^8.0.1",
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"baseline-browser-mapping": "^2.10.15",
"cssnano": "^7.1.4",
"cssnano-preset-advanced": "^7.0.12",
"esbuild": "^0.28.0",
"husky": "^9.1.7",
"playwright": "^1.52.0",
"postcss-cli": "^11.0.1",
"postcss-import": "^16.1.1",
"postcss-import-url": "^7.2.0",
"postcss-url": "^10.1.4",
"prettier": "^3.8.3"
"postcss-url": "^10.1.3",
"prettier": "^3.8.1"
},
"dependencies": {
"@aws-crypto/sha256-js": "^5.2.0",
"preact": "^10.29.2"
"preact": "^10.29.1"
},
"commitlint": {
"extends": [
-16
View File
@@ -1,16 +0,0 @@
bots:
- name: block-admin-via-regex
path_regex: ^/admin(/.*)?$
action: DENY
- name: block-secret-via-cel
expression:
all:
- 'path.startsWith("/api/secret")'
action: DENY
- import: (data)/meta/default-config.yaml
status_codes:
CHALLENGE: 200
DENY: 403
-27
View File
@@ -1,27 +0,0 @@
services:
traefik:
image: traefik:v3.3
restart: always
ports:
- 8080:80
volumes:
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- ./http.yaml:/config/http.yaml:ro
anubis:
image: ko.local/anubis
restart: always
environment:
BIND: ":8080"
TARGET: " "
POLICY_FNAME: /etc/techaro/anubis.yaml
PUBLIC_URL: http://localhost:8080/.within.website/x/cmd/anubis
COOKIE_DOMAIN: localhost
USE_REMOTE_ADDRESS: "true"
volumes:
- ./anubis.yaml:/etc/techaro/anubis.yaml
backend:
image: ghcr.io/xe/x/httpdebug
pull_policy: always
restart: always
-30
View File
@@ -1,30 +0,0 @@
http:
middlewares:
anubis:
forwardAuth:
address: http://anubis:8080/.within.website/x/cmd/anubis/api/check
trustForwardHeader: true
routers:
anubis-assets:
rule: Host(`localhost`) && PathPrefix(`/.within.website/x/cmd/anubis`)
entryPoints:
- web
service: anubis
backend:
rule: Host(`localhost`)
entryPoints:
- web
service: backend
middlewares:
- anubis
services:
anubis:
loadBalancer:
servers:
- url: http://anubis:8080
backend:
loadBalancer:
servers:
- url: http://backend:3000
-33
View File
@@ -1,33 +0,0 @@
// Smoke test for https://github.com/TecharoHQ/anubis/issues/1628
//
// Traefik's forwardAuth middleware calls Anubis at the literal path
// /.within.website/x/cmd/anubis/api/check and conveys the original URL in the
// X-Forwarded-Uri header. Path-targeting policy rules must match that header
// (not r.URL.Path), otherwise every request looks like a request to /check.
const BASE = "http://localhost:8080";
const UA = "Mozilla/5.0 (compatible; AnubisTraefikSmoke/1.0)";
const cases = [
{ path: "/", expected: 307, why: "control: no DENY rule, default challenge redirect" },
{ path: "/free", expected: 307, why: "control: no DENY rule, default challenge redirect" },
{ path: "/admin", expected: 403, why: "path_regex must match X-Forwarded-Uri, not 307 or 200" },
{ path: "/admin/users", expected: 403, why: "path_regex must match X-Forwarded-Uri, not 307 or 200" },
{ path: "/api/secret", expected: 403, why: "CEL path must match X-Forwarded-Uri, not 307 or 200" },
];
let failed = false;
for (const c of cases) {
const resp = await fetch(`${BASE}${c.path}`, {
headers: { "User-Agent": UA },
redirect: "manual",
});
const ok = resp.status === c.expected;
console.log(
`${ok ? "PASS" : "FAIL"}: GET ${c.path}${resp.status} (want ${c.expected}: ${c.why})`,
);
if (!ok) failed = true;
}
process.exit(failed ? 1 : 0);
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
export VERSION=${GITHUB_SHA:-devel}-test
export KO_DOCKER_REPO=ko.local
set -u
source ../lib/lib.sh
build_anubis_ko
function cleanup() {
docker compose down -t 1 || :
}
trap cleanup EXIT SIGINT
docker compose up -d
backoff-retry --try-count 20 node ./test.mjs
-8
View File
@@ -1,8 +0,0 @@
entryPoints:
web:
address: ":80"
providers:
file:
directory: /config
watch: false
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
+2 -2
View File
@@ -17,8 +17,8 @@ $`npm run assets`;
},
build: ({ bin, etc, systemd, doc }) => {
$`go build -o ${bin}/anubis -ldflags '-s -w -extldflags "-static" ./cmd/anubis`;
$`go build -o ${bin}/anubis-robots2policy -ldflags '-s -w -extldflags "-static"' ./cmd/robots2policy`;
$`go build -o ${bin}/anubis -ldflags '-s -w -extldflags "-static" -X "github.com/TecharoHQ/anubis.Version=${git.tag()}"' ./cmd/anubis`;
$`go build -o ${bin}/anubis-robots2policy -ldflags '-s -w -extldflags "-static" -X "github.com/TecharoHQ/anubis.Version=${git.tag()}"' ./cmd/robots2policy`;
file.install("./run/anubis@.service", `${systemd}/anubis@.service`);
file.install("./run/default.env", `${etc}/default.env`);