v1.3.12 — Block-mode CRS coverage + correct UI counter attribution¶
Two AppSec bugs surfaced by the v1.3.11 dogfood pass: block mode quietly missed every OWASP attack class, and the panel UI counter retroactively reclassified historical hits after a mode swap.
Bug A — block mode was not detecting OWASP attacks (CRITICAL)¶
Reproduction¶
Mode = block. Real prod stack.
$ curl -A 'sqlmap/1.0' https://app.example.com/?id=1%27%20OR%20%271%27%3D%271
HTTP/2 302 ← passthrough to backend, NO 403
$ docker exec argos-prod-crowdsec cscli alerts list --since 1m
No active alerts
Root cause¶
crowdsec/acquis.d/appsec.yaml (block mode, port 7422) used the vendor crowdsecurity/appsec-default config:
name: crowdsecurity/appsec-default
default_remediation: ban
inband_rules:
- crowdsecurity/base-config
- crowdsecurity/vpatch-*
- crowdsecurity/generic-*
# crowdsecurity/crs deliberately NOT in inband
Vendor omits CRS for a reason: CRS is anomaly-scoring (OWASP CRS-style) and the false-positive rate against legitimate production traffic is non-trivial. Their guidance is "use detect mode to see what CRS would catch, then audit". argos detect mode added CRS to inband in v1.3.10 once the SendAlert() plumbing was in place; block mode was the symmetric follow-up that v1.3.10 forgot. Net effect: detect saw the OWASP top-10, block did not see them.
Fix¶
New file: crowdsec/appsec-configs/argos-appsec-block.yaml, mirror of argos-appsec-detect.yaml but with default_remediation: ban:
name: argos/appsec-block
default_remediation: ban
on_match:
- filter: IsInBand == true
apply: [SendAlert()]
- filter: IsOutBand == true
apply: [SendAlert()]
inband_rules:
- crowdsecurity/base-config
- crowdsecurity/crs
- crowdsecurity/vpatch-*
- crowdsecurity/generic-*
outofband_rules:
- crowdsecurity/experimental-*
- crowdsecurity/appsec-generic-test
crowdsec/acquis.d/appsec.yaml switched to argos/appsec-block (was crowdsecurity/appsec-default). setup-appsec.sh now copies both argos-appsec-block.yaml and argos-appsec-detect.yaml into the shared volume so a fresh install gets parity coverage out of the box.
We knowingly accept the upstream-flagged false-positive risk: the argos use case is a homelab, not a public-cloud SaaS. The operator can disable specific rules per-host via the panel WAF page or flip back to detect mode in a click if a legitimate endpoint trips. Documented at docs/operations/troubleshooting.md > AppSec false-positives.
Smoke verification¶
$ curl --resolve app.example.com:80:127.0.0.1 -o /dev/null -w 'HTTP %{http_code}\n' \
'http://app.example.com/?id=1%27%20UNION%20SELECT%20NULL%2CNULL--'
HTTP 403 ← was 302 pre-fix
$ curl -A 'sqlmap/1.0' --resolve app.example.com:80:127.0.0.1 \
-o /dev/null -w 'HTTP %{http_code}\n' 'http://app.example.com/'
HTTP 403
$ curl --resolve app.example.com:80:127.0.0.1 \
-o /dev/null -w 'HTTP %{http_code}\n' \
'http://app.example.com/?q=%3Cscript%3Ealert%281%29%3C%2Fscript%3E'
HTTP 403
$ curl --resolve app.example.com:80:127.0.0.1 \
-o /dev/null -w 'HTTP %{http_code}\n' \
'http://app.example.com/?file=..%2F..%2F..%2F..%2Fetc%2Fpasswd'
HTTP 403
$ curl --resolve home.example.com:80:127.0.0.1 \
-o /dev/null -w 'HTTP %{http_code}\n' 'http://home.example.com/'
HTTP 200 ← legitimate traffic still passes through
CrowdSec acquisition log confirms argos-appsec is now using the new config:
loading /etc/crowdsec/appsec-configs/argos-appsec-block.yaml
component=appsec_config name=argos-appsec
loading inband rule crowdsecurity/crs
component=appsec_config name=argos-appsec
Bug B — UI counter retroactively reclassified detect hits as blocked after a mode swap¶
Reproduction¶
24h window with 15 detect-mode hits. Operator flips to block. Panel AppSec page now shows Total: 15, Blocked: 15, Logged: 0 -- even though zero of those 15 requests were actually blocked (detect mode let them all through, the bouncer returned 200).
Root cause¶
backend/internal/appsec/metrics.go aggregated alerts through a single boolean computed at request time:
blocking := mode == "block"
for _, a := range alerts {
if blocking { out.Blocked++ } else if mode == "detect" { out.Logged++ }
}
The mode variable is read fresh each call from db.GetSettingValue(ctx, h.DB, "appsec.mode", ...). So the moment the operator flipped to block, the next metrics fetch attributed every alert in the window -- past, present, pre-the-swap -- as if it had been a block-mode hit.
Fix¶
Two-tier per-alert classification in appsec.classifyOutcome():
-
Decisions array wins. If CrowdSec attached a non-empty
decisionsarray to the alert, it was definitively blocked (the bouncer applied the verdict AND LAPI created a ban decision). CRS-anomaly events don't currently populate this array, but vpatch / native bucket overflows do, and future CrowdSec versions may extend the coverage. This branch is the ground truth; ignored elsewhere only because it's empty for our common case. -
Timestamp + mode boundary. Otherwise, compare the alert's
created_atto the newappsec.previous_mode/appsec.last_mode_change_atsettings:- alert older than the boundary →
previous_modedecides. - alert at-or-after the boundary → current
modedecides.block→ blocked, anything else (detect,disabled, "") → logged.
- alert older than the boundary →
AppSecPatchMode handler now persists appsec.previous_mode whenever the operator flips, so the metrics path has the prior-mode value to attribute against.
Smoke verification¶
After the fix, with prod state mode = block, previous_mode = detect, last_mode_change_at = 2026-04-25T10:52:35Z:
15 historical detect-mode hits stay correctly attributed as logged; 4 post-swap block-mode hits attribute as blocked. A subsequent block→detect flip would correctly preserve the 4 as historical-blocked while new detect hits attribute to logged.
Multi-swap edge case: only the most recent boundary is tracked. A flip pattern detect → block → detect → block would attribute hits older than the LAST boundary as if they all came from the mode active at that boundary's prior side. Documented as a known limitation; full per-event attribution would require either a mode-change log table or per-alert outcome storage at write time (future work).
Implementation details¶
crowdsec.Alert.Decisions¶
Added the Decisions []AlertDecision field to the existing Alert struct in backend/internal/crowdsec/types.go. Existing JSON unmarshal calls automatically populate it from the upstream payload (CrowdSec already emitted it; argos was just discarding the field). New WasBlocked() helper returns len(a.Decisions) > 0.
appsec.classifyOutcome()¶
Pure function:
func classifyOutcome(a crowdsec.Alert, mode, prevMode string, boundary time.Time) bool {
if a.WasBlocked() {
return true
}
modeForHit := mode
if !boundary.IsZero() && prevMode != "" {
if ts := a.CreatedAt(); !ts.IsZero() && ts.Before(boundary) {
modeForHit = prevMode
}
}
return modeForHit == "block"
}
Called from the existing Provider.compute loop. Replaces the single-boolean blocking flag.
Provider.Metrics signature¶
Extended from (ctx, window, mode) to (ctx, window, mode, prevMode, lastChangeAt). Cache key now includes the boundary triple so a fresh swap (which rewrites previous_mode + last_mode_change_at) misses the cache and recomputes. The handler Invalidate() call after a swap is still in place as defense-in-depth.
Tests (13 new, all passing)¶
internal/crowdsec/types_test.go-- 5 tests onWasBlocked(): ban decision blocks; empty array logs; missing field logs; captcha counts as blocked; multiple decisions still blocked. Plus a real-payload roundtrip test.internal/appsec/metrics_test.go-- 8 tests onclassifyOutcome: decision-wins, no-boundary fallback, detect→block historical preservation (the exact bug B fix), post-swap block attribution, block→detect reverse swap, disabled mode, exact-boundary timestamp, unparseable timestamp.
Files changed¶
crowdsec/appsec-configs/argos-appsec-block.yaml(new) -- block-mode local config with CRS inband.crowdsec/acquis.d/appsec.yaml-- switchedappsec_config: argos/appsec-block.crowdsec/setup-appsec.sh-- now copies both detect and block configs into the shared volume.backend/internal/crowdsec/types.go--Alert.Decisions+WasBlocked().backend/internal/crowdsec/types_test.go(new).backend/internal/appsec/metrics.go--classifyOutcomehelper,Provider.Metricssignature gainsprevMode + lastChangeAt, cache key includes them.backend/internal/appsec/metrics_test.go(new).backend/internal/api/appsec.go-- handler reads the two new settings + persistsappsec.previous_modeon swap.docs/features/appsec.md-- setup section mentions the new block config.
Upgrade¶
cd argos-edge
git pull
docker compose build
docker compose up -d
docker compose exec crowdsec /setup-appsec.sh
setup-appsec.sh copies the new argos-appsec-block.yaml over the placeholder slot, edits the block acquisition to point at it, then SIGHUPs CrowdSec. ~1 s of block-listener downtime, no other impact. Detect-mode listener is unaffected.
Not changed¶
- v1.3.11's
argos userCLI is unchanged. - v1.3.10's detect-mode CRS coverage is unchanged.
- The "boot warnings: conflicting id" cosmetic issue from v1.3.8 is unaffected -- still ~190 warnings on every reload, still functional impact: none.