Skip to content
Merged
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
53 changes: 53 additions & 0 deletions plugins/alerts/fortigate_alert_volume_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package main

// Fabricated FortiGate SSL-VPN failure lines run through the step-by-step
// FortiGate filter model (fortiParse) and the pinned SDK CEL. They pin the
// de-duplication keys that keep the VPN brute force rule from opening a new
// alert for every user name one source address tries. The rule's history
// threshold is pinned separately by TestFortiGateSDKHistory.
import (
"reflect"
"testing"

"github.com/threatwinds/go-sdk/plugins"
"github.com/tidwall/gjson"
)

func TestFortiGateVPNBruteForceAlertVolume(t *testing.T) {
cfg, cache := fortiConfig(t), plugins.NewCELCache("fortigate-alert-volume")
r := fortiRules(t)["fortigate_vpn_brute_force"]
if r == nil {
t.Fatal("missing fortigate_vpn_brute_force")
}
want := []string{"dataSource", "lastEvent.log.devid", "lastEvent.log.vd", "adversary.ip"}
if len(r.GroupBy) != 0 || !reflect.DeepEqual(r.DeduplicateBy, want) {
t.Fatalf("grouping got groupBy %v deduplicateBy %v, want deduplicateBy %v", r.GroupBy, r.DeduplicateBy, want)
}
// Two user names from one address: the keys must be the same, so the second
// failure after the threshold is a duplicate rather than a new alert.
var keys []string
for _, user := range []string{"vpn-user", "another-user"} {
raw := `<189>date=2026-09-29 time=10:00:00 devid="FGT-LAB" vd="root" type="event" subtype="vpn" logid="0101039426" action="ssl-login-fail" logdesc="SSL VPN login fail" remip=203.0.113.10 user="` + user + `" group="Remote Users" msg="SSL user failed to log in"`
out := fortiParse(t, cfg, raw, "firewall-lab", cache)
if got, err := cache.Eval(r.Where, out); err != nil || !got {
t.Fatalf("where got %v (%v) for %s", got, err, out)
}
key := ""
for key2, path := range map[string]string{"dataSource": "dataSource", "lastEvent.log.devid": "log.devid", "lastEvent.log.vd": "log.vd", "adversary.ip": "origin.ip"} {
v := gjson.Get(out, path)
if v.Type != gjson.String || v.String() == "" {
t.Fatalf("de-duplication key %s (%s) does not resolve in %s", key2, path, out)
}
}
for _, path := range []string{"dataSource", "log.devid", "log.vd", "origin.ip"} {
key += gjson.Get(out, path).String() + "|"
}
if gjson.Get(out, "origin.user").String() != user {
t.Fatalf("user %s not parsed in %s", user, out)
}
keys = append(keys, key)
}
if keys[0] != keys[1] {
t.Fatalf("keys differ by user name: %v", keys)
}
}
2 changes: 1 addition & 1 deletion plugins/alerts/fortigate_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ func TestFortiGateRawContracts(t *testing.T) {
t.Fatal(e)
}
// Indexed lastEvent aliases require the separate alert foundation.
for _, field := range r.GroupBy {
for _, field := range append(append([]string{}, r.GroupBy...), r.DeduplicateBy...) {
path := strings.Replace(field, "lastEvent.", "events.0.", 1)
if field == "adversary.ip" && ev.GetOrigin().GetIp() != "" && gjson.Get(*wire, path).String() != ev.GetOrigin().GetIp() {
t.Error("actor IP lost")
Expand Down
11 changes: 7 additions & 4 deletions rules/fortinet/fortinet/fortigate_vpn_brute_force.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Rule version v1.0.0
# Rule version v1.1.0

dataTypes:
- firewall-fortigate-traffic
Expand All @@ -25,6 +25,9 @@ description: |
6. Check if the VPN portal is exposed to the internet unnecessarily

Generic IPsec negotiation errors and routine tunnel shutdowns are not authentication-failure evidence for this rule.

One alert is raised per firewall, VDOM and source address, however many user names it tries; repeats are suppressed
for seven days.
where: |
exists("log.devid") && !equals("log.devid", "") && exists("log.vd") && !equals("log.vd", "") &&
(equals("log.type", "event") && equals("log.subtype", "vpn") &&
Expand All @@ -50,8 +53,8 @@ afterEvents:
value: match
within: 15m
count: 10
groupBy:
- adversary.ip
- adversary.user
deduplicateBy:
- dataSource
- lastEvent.log.devid
- lastEvent.log.vd
- adversary.ip
Loading