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
13 changes: 2 additions & 11 deletions references/action.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,8 @@ The `examples/` modules are thin consumers, not logic holders:
suppresses the limiter, allowing any depth after the prefix.

The last segment decomposes into `action_name` / `action_ext` by splitting on
the **first** dot (`parseLastSegment`): `archive.tar.gz` -> name `archive`, ext
`tar.gz`. See the R-002 note in §4.3 - the hits side and this side use the same
split.
the **first** dot (`parseLastSegment`, via `strings.Cut`): `archive.tar.gz` ->
name `archive`, ext `tar.gz`. The hits side (§4.3) uses the same split.

### 4.3 Hit-derived path-to-action

Expand All @@ -137,14 +136,6 @@ path has no `*` / `**` / global-wildcard handling - it always fully decomposes
the observed path. It still emits the terminating `path[N]` absent limiter that
fixes the depth (there is just no `**` case to suppress it).

> **Known bug (R-002):** the code currently splits on the **last** dot
> (`strings.LastIndex`) at both sites - `actionNameExtConditions`
> (`data_source_hits.go:760`) and `parseLastSegment`
> (`action_reverse_map.go:441`) - so `archive.tar.gz` wrongly yields ext `gz` and
> the hit-derived scope disagrees with the API (a hard error in the hits flow).
> The fix is `strings.Index` at both sites; tracked as R-002. This section
> describes the intended first-dot behavior. See `hits-to-rules.md §4.4`.

### 4.4 Condition normalization

- `iequal` values are downcased server-side; the provider mirrors this so state
Expand Down
5 changes: 1 addition & 4 deletions references/hits-to-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,7 @@ WithAttackType)`, Delete through `resourcerule.Delete`, Import through
`absent` condition one index past the last segment (fixes chain length).
- final path segment splits into `action_name` + `action_ext` on the **first**
dot, matching the API (`archive.tar.gz` -> name `archive`, ext `tar.gz`); no
dot -> `action_name` = segment and `action_ext` `absent`. **Known bug (R-002):**
the code currently splits on the *last* dot (`actionNameExtConditions` here and
`parseLastSegment` on the `action_path` side); the fix is `strings.Index` at
both sites. See `action.md §4.3`.
dot -> `action_name` = segment and `action_ext` `absent`.
- root path `/` -> `action_name` empty + `path[0]` absent.
- `path == "[multiple]"` -> host-only wildcard scope (no path/action_name/
action_ext conditions).
Expand Down
12 changes: 6 additions & 6 deletions references/rules-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,9 @@ the full algorithm (wildcards, headers, query, root path, `uri` exclusivity) is
in `action.md`. Point values chain through the parser tree as 2D
paired/simple lists (`point.md`, authority `WrapPointElements`).

> **Known bug (R-002):** the path decomposition currently splits the final
> segment on the *last* dot at both sites (`actionNameExtConditions`,
> `parseLastSegment`); the correct/API split is the *first* dot. See
> `action.md §4.3` and `hits-to-rules.md §4.4`.
The final path segment decomposes into `action_name` / `action_ext` on the
**first** dot at both sites (`actionNameExtConditions`, `parseLastSegment`),
matching the API. See `action.md §4.3`.

### 4.3 Variativity (load-bearing)

Expand Down Expand Up @@ -329,7 +328,8 @@ live set: `GET /v2/attack_types`. Offline: `proton-types.md`.
- `proton-types.md` - Proton type/attack-type IDs.
- `rules_api_fields.md` - probe-derived per-hint field ground truth.
- `schema-decisions.md` - schema attribute decision tree.
- `spec/actions_examples.json` - 82 representative action condition examples
(one per distinct shape; deduped from a 343-sample probe).
- `spec/actions_examples.json` - representative action condition examples (unique
shapes deduped from a 343-sample probe, plus the `/.env` dotfile case). Single
source; the round-trip test reads this file directly.
- `create-rule-resource` skill - canonical build flow for a new `rule_*` resource.
- `hits-to-rules.md` - FP-suppression rules from hits. Counters/triggers: T-004.
44 changes: 44 additions & 0 deletions spec/actions_examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -4772,5 +4772,49 @@
],
"instance": "5",
"path": "/api/v2/project/*/items/physical_good/delivery/shipping_methods/cart/current"
},
{
"conditions": [
{
"point": [
"instance"
],
"type": "equal",
"value": "-1"
},
{
"point": [
"header",
"HOST"
],
"type": "iequal",
"value": "example.com"
},
{
"point": [
"path",
0
],
"type": "absent",
"value": null
},
{
"point": [
"action_name"
],
"type": "equal",
"value": ""
},
{
"point": [
"action_ext"
],
"type": "equal",
"value": "env"
}
],
"domain": "example.com",
"instance": "-1",
"path": "/.env"
}
]
7 changes: 2 additions & 5 deletions wallarm/common/resourcerule/action_reverse_map.go
Original file line number Diff line number Diff line change
Expand Up @@ -438,11 +438,8 @@ func expandPath(path string) []wallarm.ActionDetails {

// parseLastSegment splits "name.ext" into name and ext parts.
func parseLastSegment(seg string) (name, ext string, hasDot bool) {
dotIdx := strings.LastIndex(seg, ".")
if dotIdx < 0 {
return seg, "", false
}
return seg[:dotIdx], seg[dotIdx+1:], true
name, ext, hasDot = strings.Cut(seg, ".")
return
}

// --- Helper functions ---
Expand Down
28 changes: 25 additions & 3 deletions wallarm/common/resourcerule/action_reverse_map_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ func TestReverseMapActions(t *testing.T) {
}
}

// TestReverseMapRealExamples validates against 343 real API examples.
// TestReverseMapRealExamples validates against the curated real API examples in
// spec/actions_examples.json (the single source; see rules-core.md).
func TestReverseMapRealExamples(t *testing.T) {
type example struct {
Conditions []wallarm.ActionDetails `json:"conditions"`
Expand All @@ -167,7 +168,7 @@ func TestReverseMapRealExamples(t *testing.T) {
Proto string `json:"proto"`
}

data, err := os.ReadFile("testdata/actions_examples.json")
data, err := os.ReadFile("../../../spec/actions_examples.json")
if err != nil {
t.Skipf("Skipping real examples test: %v", err)
}
Expand Down Expand Up @@ -201,6 +202,27 @@ func TestReverseMapRealExamples(t *testing.T) {
}

// TestExpandPathToActions tests the forward mapping.
func TestParseLastSegment(t *testing.T) {
tests := []struct {
name, seg, wantName, wantExt string
wantHasDot bool
}{
{"no dot", "login", "login", "", false},
{"single dot", "data.json", "data", "json", true},
// A multi-dot segment splits on the FIRST dot to match the API.
{"multi dot", "archive.tar.gz", "archive", "tar.gz", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
name, ext, hasDot := parseLastSegment(tt.seg)
if name != tt.wantName || ext != tt.wantExt || hasDot != tt.wantHasDot {
t.Errorf("parseLastSegment(%q) = (%q, %q, %v), want (%q, %q, %v)",
tt.seg, name, ext, hasDot, tt.wantName, tt.wantExt, tt.wantHasDot)
}
})
}
}

func TestExpandPathToActions(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -371,7 +393,7 @@ func TestRealExamplesRoundTrip(t *testing.T) {
Headers []HeaderParam `json:"headers"`
}

data, err := os.ReadFile("testdata/actions_examples.json")
data, err := os.ReadFile("../../../spec/actions_examples.json")
if err != nil {
t.Skipf("Skipping: %v", err)
}
Expand Down
30 changes: 30 additions & 0 deletions wallarm/common/resourcerule/action_scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,32 @@ func scopeActionSchema(forceNew, computed bool) *schema.Schema {
//
// Also validates that "uri" conditions are not mixed with "path", "action_name",
// "action_ext", or "query" conditions (mutually exclusive in the Wallarm API).
// validateActionPath rejects an action_path whose wildcard tokens are malformed.
// A "*" is valid only as a whole path segment, whole action_name, or whole
// action_ext; "**" only as a whole directory segment. A "*" fused into a larger
// value (e.g. "report.2024.*") would silently build a rule that matches nothing,
// so it is rejected at plan time.
func validateActionPath(path string) error {
if path == "" || path == "/" || path == pathGlobalWildcard {
return nil
}
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
last := parts[len(parts)-1]
for _, seg := range parts[:len(parts)-1] {
if strings.Contains(seg, "*") && seg != "*" && seg != "**" {
return fmt.Errorf("action_path %q: %q is not a valid path segment - use a literal, \"*\" (any one segment), or \"**\" (any depth)", path, seg)
}
}
name, ext, hasDot := strings.Cut(last, ".")
if strings.Contains(name, "*") && name != "*" {
return fmt.Errorf("action_path %q: %q is not a valid filename - use a literal or \"*\" (any name)", path, name)
}
if hasDot && strings.Contains(ext, "*") && ext != "*" {
return fmt.Errorf("action_path %q: %q is not a valid extension - use a literal or \".*\" (any extension)", path, ext)
}
return nil
}

func ActionScopeCustomizeDiff(_ context.Context, d *schema.ResourceDiff, _ any) error {
// Validate explicit action blocks (point keys, URI conflicts, type/value rules).
if err := validateActionBlocks(d); err != nil {
Expand All @@ -212,6 +238,10 @@ func ActionScopeCustomizeDiff(_ context.Context, d *schema.ResourceDiff, _ any)
// Check if scope fields are set in config.
actionPath := d.Get("action_path").(string)

if err := validateActionPath(actionPath); err != nil {
return err
}

hasScopeFields := actionPath != "" ||
d.Get("action_domain").(string) != "" ||
d.Get("action_instance").(string) != "" ||
Expand Down
28 changes: 28 additions & 0 deletions wallarm/common/resourcerule/action_scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,34 @@ import (
wallarm "github.com/wallarm/wallarm-go"
)

func TestValidateActionPath(t *testing.T) {
valid := []string{
"", "/", "/**/*.*",
"/reports/report.*", // ext wildcard
"/reports/*", // name wildcard
"/reports/*.json", // name wildcard + literal ext
"/api/*/users", // segment wildcard
"/api/**/users", // globstar
"/dl/archive.tar.gz", // multi-dot, no wildcard
}
for _, p := range valid {
if err := validateActionPath(p); err != nil {
t.Errorf("validateActionPath(%q) = %v, want nil", p, err)
}
}
invalid := []string{
"/reports/report.2024.*", // "*" fused into the extension
"/reports/re*port", // "*" fused into the name
"/reports/report.j*son", // "*" fused into the extension
"/api/rep*/users", // "*" fused into a segment
}
for _, p := range invalid {
if err := validateActionPath(p); err == nil {
t.Errorf("validateActionPath(%q) = nil, want error", p)
}
}
}

func TestValidateActionSet_Valid(t *testing.T) {
set := newActionSet(
map[string]any{"type": "iequal", "value": "example.com", "point": map[string]any{"header": "HOST"}},
Expand Down
Loading
Loading