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
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ jobs:

- name: Run tests
run: go test ./...

- name: Lint projection declarations
run: make lint-projection

- name: Run tests with coverage
run: go test -coverprofile=coverage.out ./...
Expand Down
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.PHONY: lint-projection
lint-projection:
go run ./cmd/lint-projection ./pkg/rules
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,51 @@ Defines the detection logic:
7. **Test both positive and negative scenarios**
8. **Validate generated YAML** before deployment

## Declaring `profileDataRequired`

When a rule's `profileDependency` is `Required` (0) or `Optional` (1), it must declare
`profileDataRequired` listing which profile surfaces the rule queries at runtime. Look at
every CEL guard that passes an event field to a profile helper (`ap.*` / `nn.*`) and
translate the argument into a pattern under the correct surface key:

| Event field | Surface key |
|---|---|
| `event.path` / `event.exepath` (open events) | `opens` |
| `event.path` / `event.exepath` (exec events) | `execs` |
| `event.syscallName` | `syscalls` |
| `event.capName` | `capabilities` |
| `event.name` (DNS) | `egressDomains` |
| `event.dstAddr` / `event.dstIp` (network) | `egressAddresses` |
| `event.endpoint` (HTTP) | `endpoints` |

| Guard operator | Pattern type |
|---|---|
| `==` | `exact` |
| `.startsWith(...)` | `prefix` |
| `.endsWith(...)` | `suffix` |
| `.contains(...)` | `contains` |

If the helper receives a fully dynamic argument with no co-located literal guard, declare
the surface as `all`.

Example:

```yaml
profileDependency: 0
profileDataRequired:
opens:
- exact: "/var/run/docker.sock"
- prefix: "/etc/cron.d/"
execs: all
```

The lint at `cmd/lint-projection/` enforces that declarations are present and schema-valid.
Run it locally with `make lint-projection`. Getting the patterns right is the rule author's
responsibility — runtime metrics in node-agent catch drift after deployment.

Rules with `profileDependency: 2` (NotRequired) must **not** declare `profileDataRequired`;
the lint emits a warning if they do.

## Contributing

1. Fork the repository
Expand Down
140 changes: 140 additions & 0 deletions cmd/lint-projection/check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package main

import (
"fmt"
"os"

"github.com/armosec/armoapi-go/armotypes"
"gopkg.in/yaml.v3"
)

type Severity string

const (
SeverityError Severity = "ERROR"
SeverityWarn Severity = "WARN"
)

type Finding struct {
File string
Line int
RuleID string
Severity Severity
Check string
Message string
}

func (f Finding) String() string {
return fmt.Sprintf("%s:%d: %s: %s: %s: %s", f.File, f.Line, f.RuleID, f.Severity, f.Check, f.Message)
}

// ruleDoc mirrors the relevant fields of a kubescape/rulelibrary rule YAML.
// We only need profileDependency and profileDataRequired here.
type ruleDoc struct {
Spec struct {
Rules []struct {
ID string `yaml:"id"`
ProfileDependency armotypes.ProfileDependency `yaml:"profileDependency"`
ProfileDataRequired *armotypes.ProfileDataRequired `yaml:"profileDataRequired,omitempty"`
} `yaml:"rules"`
} `yaml:"spec"`
}

func lintFiles(files []string) []Finding {
var findings []Finding
for _, path := range files {
findings = append(findings, lintFile(path)...)
}
return findings
}

func lintFile(path string) []Finding {
data, err := os.ReadFile(path)
if err != nil {
return []Finding{{File: path, Line: 0, RuleID: "?", Severity: SeverityError, Check: "C3", Message: fmt.Sprintf("read failed: %v", err)}}
}
var doc ruleDoc
if err := yaml.Unmarshal(data, &doc); err != nil {
return []Finding{{File: path, Line: 0, RuleID: "?", Severity: SeverityError, Check: "C3", Message: fmt.Sprintf("yaml unmarshal: %v", err)}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
lineByID := indexRuleLines(data)

var findings []Finding
for _, r := range doc.Spec.Rules {
line := lineByID[r.ID]
findings = append(findings, checkRule(path, line, r.ID, r.ProfileDependency, r.ProfileDataRequired)...)
}
return findings
}

func checkRule(path string, line int, id string, dep armotypes.ProfileDependency, pdr *armotypes.ProfileDataRequired) []Finding {
var out []Finding
switch dep {
Comment thread
slashben marked this conversation as resolved.
case armotypes.Required, armotypes.Optional:
if pdr == nil || pdr.IsEmpty() {
out = append(out, Finding{File: path, Line: line, RuleID: id, Severity: SeverityError, Check: "C1",
Message: fmt.Sprintf("rule has profileDependency=%v; profileDataRequired must be present and declare at least one surface", profileDependencyName(dep))})
}
case armotypes.NotRequired:
if pdr != nil {
out = append(out, Finding{File: path, Line: line, RuleID: id, Severity: SeverityWarn, Check: "C4",
Message: "rule has profileDependency=NotRequired but declares profileDataRequired; if the rule does not query profile data, remove the declaration"})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
default:
out = append(out, Finding{File: path, Line: line, RuleID: id, Severity: SeverityError, Check: "C5",
Message: fmt.Sprintf("rule has unrecognized profileDependency value %d; must be 0 (Required), 1 (Optional), or 2 (NotRequired)", int(dep))})
}
if pdr != nil && !pdr.IsEmpty() {
if err := pdr.Validate(); err != nil {
out = append(out, Finding{File: path, Line: line, RuleID: id, Severity: SeverityError, Check: "C2", Message: err.Error()})
}
}
return out
}

func profileDependencyName(d armotypes.ProfileDependency) string {
switch d {
case armotypes.Required:
return "Required"
case armotypes.Optional:
return "Optional"
case armotypes.NotRequired:
return "NotRequired"
}
return fmt.Sprintf("Unknown(%d)", d)
}

func indexRuleLines(data []byte) map[string]int {
out := map[string]int{}
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return out
}
if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
return out
}
root := doc.Content[0]
spec := findMappingValue(root, "spec")
rules := findMappingValue(spec, "rules")
if rules == nil || rules.Kind != yaml.SequenceNode {
return out
}
for _, ruleMap := range rules.Content {
if id := findMappingValue(ruleMap, "id"); id != nil {
out[id.Value] = id.Line
}
}
return out
}

func findMappingValue(n *yaml.Node, key string) *yaml.Node {
if n == nil || n.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(n.Content); i += 2 {
if n.Content[i].Value == key {
return n.Content[i+1]
}
}
return nil
}
54 changes: 54 additions & 0 deletions cmd/lint-projection/check_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package main

import (
"strings"
"testing"

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

func TestLintFile(t *testing.T) {
cases := []struct {
path string
wantSeverities []Severity
wantSubstrs []string
}{
{"testdata/valid-required.yaml", nil, nil},
{"testdata/valid-optional-all.yaml", nil, nil},
{"testdata/valid-notrequired-no-decl.yaml", nil, nil},
{"testdata/error-required-missing.yaml", []Severity{SeverityError}, []string{"C1", "must be present"}},
{"testdata/error-required-empty.yaml", []Severity{SeverityError}, []string{"C1", "at least one surface"}},
{"testdata/error-bad-pattern-multikey.yaml", []Severity{SeverityError}, []string{"C2", "exactly one"}},
{"testdata/error-field-empty.yaml", []Severity{SeverityError}, []string{"C2", "at least one pattern"}},
{"testdata/warn-notrequired-with-decl.yaml", []Severity{SeverityWarn}, []string{"C4"}},
{"testdata/error-bad-dependency-value.yaml", []Severity{SeverityError}, []string{"C5", "unrecognized profileDependency"}},
}
for _, c := range cases {
t.Run(c.path, func(t *testing.T) {
findings := lintFile(c.path)
require.Len(t, findings, len(c.wantSeverities), "unexpected finding count: %v", findings)
for i, sev := range c.wantSeverities {
assert.Equal(t, sev, findings[i].Severity)
}
joined := ""
for _, f := range findings {
joined += f.String() + "\n"
}
for _, s := range c.wantSubstrs {
assert.True(t, strings.Contains(joined, s), "expected %q in output:\n%s", s, joined)
}
})
}
}

func TestLintExitCode(t *testing.T) {
findings := lintFiles([]string{"testdata/valid-required.yaml", "testdata/error-required-missing.yaml"})
hasError := false
for _, f := range findings {
if f.Severity == SeverityError {
hasError = true
}
}
assert.True(t, hasError)
}
50 changes: 50 additions & 0 deletions cmd/lint-projection/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package main

import (
"fmt"
"os"
"path/filepath"
"strings"
)

func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: lint-projection DIR [DIR ...]")
os.Exit(2)
}
var files []string
for _, dir := range os.Args[1:] {
matches, err := walkYAMLs(dir)
if err != nil {
fmt.Fprintf(os.Stderr, "walk %s: %v\n", dir, err)
os.Exit(2)
}
files = append(files, matches...)
}
findings := lintFiles(files)
for _, f := range findings {
fmt.Println(f.String())
}
for _, f := range findings {
if f.Severity == SeverityError {
os.Exit(1)
}
}
}

func walkYAMLs(root string) ([]string, error) {
var out []string
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml") {
out = append(out, path)
}
return nil
})
return out, err
}
4 changes: 4 additions & 0 deletions cmd/lint-projection/testdata/error-bad-dependency-value.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
spec:
rules:
- id: R9999
profileDependency: 99
8 changes: 8 additions & 0 deletions cmd/lint-projection/testdata/error-bad-pattern-multikey.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
spec:
rules:
- id: R0002
profileDependency: 0
profileDataRequired:
opens:
- exact: "/a"
prefix: "/b"
6 changes: 6 additions & 0 deletions cmd/lint-projection/testdata/error-field-empty.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
spec:
rules:
- id: R0002
profileDependency: 0
profileDataRequired:
opens: []
5 changes: 5 additions & 0 deletions cmd/lint-projection/testdata/error-required-empty.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
spec:
rules:
- id: R0001
profileDependency: 0
profileDataRequired: {}
4 changes: 4 additions & 0 deletions cmd/lint-projection/testdata/error-required-missing.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
spec:
rules:
- id: R0001
profileDependency: 0
4 changes: 4 additions & 0 deletions cmd/lint-projection/testdata/valid-notrequired-no-decl.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
spec:
rules:
- id: R1000
profileDependency: 2
7 changes: 7 additions & 0 deletions cmd/lint-projection/testdata/valid-optional-all.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
spec:
rules:
- id: R0010
profileDependency: 1
profileDataRequired:
opens:
- prefix: "/etc/shadow"
6 changes: 6 additions & 0 deletions cmd/lint-projection/testdata/valid-required.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
spec:
rules:
- id: R0001
profileDependency: 0
profileDataRequired:
execs: all
7 changes: 7 additions & 0 deletions cmd/lint-projection/testdata/warn-notrequired-with-decl.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
spec:
rules:
- id: R1000
profileDependency: 2
profileDataRequired:
opens:
- exact: "/a"
Loading
Loading