Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions pkg/acquisition/modules/appsec/appsec_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1562,6 +1562,41 @@ func TestAppsecOnChallengeHooks(t *testing.T) {
HTTPRequest: &http.Request{Host: "example.com"},
},
},
{
// The score accumulator is per-request state, which on_load
// predates entirely.
name: "on_load AddRequestScore() fails to load",
expected_load_ok: false,
on_load: []appsec.Hook{
{Apply: []string{`AddRequestScore(15, "utc_timezone")`}},
},
input_request: appsec.ParsedRequest{
RemoteAddr: "1.2.3.4",
Method: "GET",
URI: "/protected",
HTTPRequest: &http.Request{Host: "example.com"},
},
},
{
name: "pre_eval score accumulates across hooks",
expected_load_ok: true,
pre_eval: []appsec.Hook{
{Filter: "true", Apply: []string{`AddRequestScore(30, "no_user_agent")`}},
{Filter: "true", Apply: []string{`AddRequestScore(20, "suspicious_path")`}},
{Filter: "RequestScore() >= 45", Apply: []string{`DropRequest("request score " + string(RequestScore()))`}},
},
input_request: appsec.ParsedRequest{
RemoteAddr: "1.2.3.4",
Method: "GET",
URI: "/protected",
HTTPRequest: &http.Request{Host: "example.com"},
},
output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
require.Len(t, responses, 1)
require.Equal(t, appsec.BanRemediation, responses[0].Action,
"50 points across two hooks must cross the 45 bar")
},
},
{
// SendChallenge in an out-of-band post_eval hook is rejected at runtime
name: "outofband post_eval SendChallenge() is rejected",
Expand Down
53 changes: 43 additions & 10 deletions pkg/appsec/appsec.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -266,6 +267,9 @@ type AppsecRequestState struct {
// nil until the first call.
LastMismatchReport *challenge.MismatchReport

// Tracks the request score + reasons
RequestScore RequestScore

// HookVars is a per-request scratch space exposed to expr hooks as
// `hook_vars`. Helpers (e.g. ValidateRequestWithSchema) publish string
// values here so that later hook expressions — including the `apply`
Expand Down Expand Up @@ -1030,9 +1034,8 @@ func (wc *AppsecConfig) Build(ctx context.Context, hub *cwhub.Hub) (*AppsecRunti
// state, when non-nil, is consulted between rule iterations: if
// state.HooksHalted is true (set by a terminal expr helper such as
// RejectSubmission or the on_challenge_submit GrantChallengeCookie),
// remaining rules in this phase are skipped. ProcessOnLoadRules and
// the non-submit phases pass nil — they have no terminal actions
// today.
// remaining rules in this phase are skipped. ProcessOnLoadRules passes
// nil — it has no request state at all.
func (w *AppsecRuntimeConfig) processHooks(hooks []Hook, env map[string]interface{}, hookType string, state *AppsecRequestState) error {
has_match := false

Expand Down Expand Up @@ -1180,9 +1183,11 @@ func (w *AppsecRuntimeConfig) ProcessOnChallengeRules(ctx context.Context, state
// itself (with the operator-chosen verbosity), so we don't
// re-log here — just serve the rejection envelope.
w.emitChallengeEvent(request, ChallengeEventInfo{
Reason: ChallengeReasonRejected,
FailReason: state.SubmissionRejection.Reason,
Fingerprint: &fpData,
Reason: ChallengeReasonRejected,
FailReason: state.SubmissionRejection.Reason,
Fingerprint: &fpData,
Score: state.RequestScore.Total(),
ScoreReasons: state.RequestScore.Reasons(),
})
return w.setChallengeResponse(state, http.StatusOK, bodyChallengeRejected,
map[string]string{"Content-Type": "application/json", "Cache-Control": "no-cache, no-store"}, nil)
Expand Down Expand Up @@ -1239,7 +1244,7 @@ func (w *AppsecRuntimeConfig) ProcessOnChallengeRules(ctx context.Context, state
return nil
}

return w.processHooks(w.CompiledOnChallenge, GetOnChallengeEnv(ctx, w, state, request), "on_challenge", nil)
return w.processHooks(w.CompiledOnChallenge, GetOnChallengeEnv(ctx, w, state, request), "on_challenge", state)
}

func (w *AppsecRuntimeConfig) ProcessPreEvalRules(ctx context.Context, state *AppsecRequestState, request *ParsedRequest) error {
Expand Down Expand Up @@ -1523,6 +1528,32 @@ func (w *AppsecRuntimeConfig) EvaluateMismatches(state *AppsecRequestState, requ
return report
}

const (
hookVarRequestScore = "request_score"
hookVarRequestScoreReasons = "request_score_reasons"
hookVarRequestScoreDetail = "request_score_detail"
)

func (w *AppsecRuntimeConfig) AddRequestScore(state *AppsecRequestState, points int, reason string) error {
total := state.RequestScore.Add(points, reason)

if state.HookVars != nil {
state.HookVars[hookVarRequestScore] = strconv.Itoa(total)
state.HookVars[hookVarRequestScoreReasons] = strings.Join(state.RequestScore.Reasons(), ",")
state.HookVars[hookVarRequestScoreDetail] = state.RequestScore.String()
}

if w.Logger != nil {
w.Logger.WithFields(log.Fields{
"reason": reason,
"points": points,
"total": total,
}).Debug("request score updated")
}

return nil
}

// emitMismatchObservability logs the report at Debug level and bumps the
// per-reason/severity Prometheus counter. Called exactly once per request
// from EvaluateMismatches (guarded by state.LastMismatchReport being nil
Expand Down Expand Up @@ -1606,9 +1637,11 @@ func (w *AppsecRuntimeConfig) SendChallenge(ctx context.Context, state *AppsecRe
}

w.emitChallengeEvent(request, ChallengeEventInfo{
Reason: ChallengeReasonRequested,
Difficulty: target,
Fingerprint: state.Fingerprint,
Reason: ChallengeReasonRequested,
Difficulty: target,
Fingerprint: state.Fingerprint,
Score: state.RequestScore.Total(),
ScoreReasons: state.RequestScore.Reasons(),
})

return nil
Expand Down
191 changes: 191 additions & 0 deletions pkg/appsec/appsec_score_hooks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package appsec

import (
"encoding/json"
"net/http"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/crowdsecurity/crowdsec/pkg/appsec/challenge"
)

// End-to-end coverage of the threshold model the shipped
// appsec-bot-challenge-scoring configs implement: several hooks each credit a
// weight, a later hook acts only once the total crosses a bar.
//
// The decision lives in on_challenge_submit because that is the gate — a
// visitor must pass it to be issued a cookie, and the fingerprint measured
// there is the one sealed into that cookie.

// scoringSignalHooks mirrors the weights config: one hook per signal, keyed on
// the aggregate mismatch report, never on the running score.
func scoringSignalHooks() []Hook {
return []Hook{
{
Filter: `EvaluateMismatches().Has("cdp")`,
Apply: []string{`AddRequestScore(100, "cdp")`},
},
{
Filter: `EvaluateMismatches().Has("timezone_country")`,
Apply: []string{`AddRequestScore(5, "timezone_country")`},
},
}
}

// scoringPolicyHook mirrors a threshold config: refuse the submission, and with
// it the cookie, once the score crosses the bar.
func scoringPolicyHook() Hook {
return Hook{
Filter: `RequestScore() >= 75`,
Apply: []string{`RejectSubmission("request score " + string(RequestScore()) + ": " + join(RequestScoreReasons(), ","), "verbose")`},
}
}

// fpEuropeParisClean is fpEuropeParisCDP with no library signal fired, so a
// US client IP leaves only the soft timezone_country mismatch.
func fpEuropeParisClean(t *testing.T) *challenge.FingerprintData {
t.Helper()

raw := `{
"signals": {
"device": {"platform": "MacIntel"},
"browser": {
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120",
"highEntropyValues": {"platform": "macOS"}
},
"locale": {
"internationalization": {"timezone": "Europe/Paris"},
"languages": {"language": "en"}
}
},
"fsid": "FS_CLEAN", "nonce": "n", "time": 1, "url": "http://x/",
"fastBotDetection": false,
"fastBotDetectionDetails": {}
}`

fp := &challenge.FingerprintData{}
require.NoError(t, json.Unmarshal([]byte(raw), fp))

return fp
}

// newScoringState builds the in-band request state the challenge dispatcher
// hands to user hooks once it has a fingerprint to inspect.
func newScoringState(t *testing.T, rt *AppsecRuntimeConfig, fp *challenge.FingerprintData) (*AppsecRequestState, *ParsedRequest) {
t.Helper()
setupGeoIP(t)

state := &AppsecRequestState{HookVars: map[string]string{}}
state.ResetResponse(rt.Config)
state.CurrentPhase = PhaseInBand // SendChallenge refuses to run out-of-band
state.Fingerprint = fp

req := newInBandRequest(http.MethodGet, "/", nil)
req.ClientIP = testIPUS // geoips to US, so a Europe/Paris tz mismatches
req.AppsecEngine = "test-engine"

return state, req
}

// runSubmitHooks drives a compiled on_challenge_submit list the way
// ProcessOnChallengeRules does — state is passed so RejectSubmission can halt
// the remaining rules.
func runSubmitHooks(t *testing.T, rt *AppsecRuntimeConfig, hooks []Hook, fp *challenge.FingerprintData) *AppsecRequestState {
t.Helper()

compiled, err := buildHookList(t.Context(), hooks, hookOnChallengeSubmit, &appsecExprPatcher{})
require.NoError(t, err)

state, req := newScoringState(t, rt, fp)

require.NoError(t, rt.processHooks(
compiled,
GetOnChallengeSubmitEnv(rt, state, req),
"on_challenge_submit",
state,
))

return state
}

// Two signals sum past the bar → no cookie is issued. RejectSubmission is
// terminal, so the trailing sentinel must not run; the accumulator doubles as
// the probe for that.
func TestOnChallengeSubmitScoreCrossesThreshold(t *testing.T) {
rt := newChallengeTestRuntime(t, nil)

hooks := append(scoringSignalHooks(),
scoringPolicyHook(),
Hook{Filter: `true`, Apply: []string{`AddRequestScore(1000, "sentinel")`}},
)

state := runSubmitHooks(t, rt, hooks, fpEuropeParisCDP(t))

assert.Equal(t, 105, state.RequestScore.Total())
assert.Equal(t, []string{"cdp", "timezone_country"}, state.RequestScore.Reasons())

require.NotNil(t, state.SubmissionRejection)
assert.Equal(t, "request score 105: cdp,timezone_country", state.SubmissionRejection.Reason)
assert.True(t, state.HooksHalted)
assert.NotContains(t, state.RequestScore.Reasons(), "sentinel", "sentinel hook must not have run")

assert.Equal(t, "105", state.HookVars[hookVarRequestScore])
assert.Equal(t, "cdp,timezone_country", state.HookVars[hookVarRequestScoreReasons])
assert.Equal(t, "cdp=100,timezone_country=5", state.HookVars[hookVarRequestScoreDetail])
}

// The motivating case: one weak signal on its own must not act. Same hooks,
// same threshold — only the evidence differs.
func TestOnChallengeSubmitSingleWeakSignalStaysBelowThreshold(t *testing.T) {
rt := newChallengeTestRuntime(t, nil)

hooks := append(scoringSignalHooks(), scoringPolicyHook())
state := runSubmitHooks(t, rt, hooks, fpEuropeParisClean(t))

assert.Equal(t, 5, state.RequestScore.Total())
assert.Equal(t, []string{"timezone_country"}, state.RequestScore.Reasons())

assert.Nil(t, state.SubmissionRejection, "a lone weak signal must still be issued a cookie")
assert.False(t, state.HooksHalted)
assert.Equal(t, "5", state.HookVars[hookVarRequestScore])
}

// Hooks are appended across configs and evaluated in order, so a policy config
// listed ahead of the weights config reads an empty score and does nothing.
func TestOnChallengeSubmitPolicyBeforeSignalsDoesNotFire(t *testing.T) {
rt := newChallengeTestRuntime(t, nil)

hooks := append([]Hook{scoringPolicyHook()}, scoringSignalHooks()...)
state := runSubmitHooks(t, rt, hooks, fpEuropeParisCDP(t))

assert.Equal(t, 105, state.RequestScore.Total(), "signals still score, just too late")
assert.Nil(t, state.SubmissionRejection, "policy hook saw an empty score")
}

// The accumulator is exposed in every request phase, not just the submit gate.
// on_challenge is the one that also has SendChallenge, so a custom config can
// escalate PoW difficulty from a score built there.
func TestOnChallengeScoreCanEscalateDifficulty(t *testing.T) {
hooks := append(scoringSignalHooks(), Hook{
Filter: `RequestScore() >= 75`,
Apply: []string{`SetChallengeDifficulty("high")`, `SendChallenge()`},
})

rt := newChallengeTestRuntime(t, hooks)
state, req := newScoringState(t, rt, fpEuropeParisCDP(t))

require.NoError(t, rt.processHooks(
rt.CompiledOnChallenge,
GetOnChallengeEnv(t.Context(), rt, state, req),
"on_challenge",
state,
))

assert.Equal(t, 105, state.RequestScore.Total())
assert.True(t, state.RequireChallenge)
assert.Equal(t, ChallengeRemediation, state.Response.Action)
require.NotNil(t, state.ChallengeDifficulty)
assert.Equal(t, challenge.PowDifficultyHigh, *state.ChallengeDifficulty)
}
Loading
Loading