Skip to content
Closed
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
5 changes: 3 additions & 2 deletions build_files/firewall/snitchwatch-system-daemon-pins.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"daemon/go.mod": "556fe4926644b3eeb5e0e5212d5b66d88f1476480508a9337a7b71e3b034626a",
"daemon/go.sum": "eded94c59f8e572018f7f5a845a6ec0e6ecd8671d55802ee37d619d831128d41"
},
"patchSha256": "d55983a15e1462c82979f4d2a0a718f98c9c7197b80468dc490a39228c85e5cf",
"patchSha256": "b49df4502aa4334140696ec386c817ed753d3edc50a33f8a1139c490cfa6eaf4",
"patchedFiles": {
"daemon/drop_while_asking_test.go": "acae364ef2421fb1dcc5a15ca6228129743ebd8afb7f968c59f17a596b15655d",
"daemon/firewall/nftables/chains.go": "bc26d889f83d4f2a14d19f8ec4ebe39d66dc2cae2d95e41627d2acb0fe0b6825",
Expand All @@ -38,7 +38,8 @@
"daemon/queue_cleanup_order_test.go": "c180b0f62b435298cb903a34616fbe61a5931b6a16eaf1d9ca9e6456fb9fd1bc",
"daemon/reader_failure_test.go": "ca10e0986dcc60e3fd94a2720695085c0d3435b05213a75df5f79c72666d9a11",
"daemon/repeat_handoff_test.go": "a6939b8f186e82d580f6c413bd8beb3d096fe696fe4a31409c982bc63d9a5360",
"daemon/rule/loader.go": "3e262467e4e55ae3d4a88238760c84594eb5797aa0191534e9ec446662c8be27",
"daemon/rule/loader.go": "35ced210a6ab96f9d0f4b702882af5db2c988295a4e37062c1c68a8c577f8f87",
"daemon/rule/loader_name_validation_test.go": "451510c142faa5aea1f106ba8dfc72471c95a1624fb2582837eac862a185abf1",
"daemon/rule/name.go": "5f6e4dbe9b918b532b64f990e2e50adbafa82f1b92c409c5e674420f0a766b83",
"daemon/rule/operator.go": "6255c6d020d8e182d55580b97f84d4f5d3d317e33d855eef9ce7f9da16d73a56",
"daemon/rule/operator_lists.go": "59ffec900333c5feba18cab26f4ef03ef430021260b7e86784cb0ba9ec0dd934",
Expand Down
165 changes: 154 additions & 11 deletions build_files/firewall/snitchwatch-system-daemon-shutdown.patch
Original file line number Diff line number Diff line change
Expand Up @@ -1096,7 +1096,7 @@ index 9c06aab7..d80761c8 100644

#endif
diff --git a/daemon/rule/loader.go b/daemon/rule/loader.go
index 3d743556..d6d6e75b 100644
index 3d743556..30205a9a 100644
--- a/daemon/rule/loader.go
+++ b/daemon/rule/loader.go
@@ -105,6 +105,8 @@ func (l *Loader) Reload(path string) error {
Expand Down Expand Up @@ -1142,21 +1142,26 @@ index 3d743556..d6d6e75b 100644
l.Lock()
defer l.Unlock()

@@ -235,10 +248,9 @@ func (l *Loader) loadRule(fileName string) error {
@@ -235,10 +248,16 @@ func (l *Loader) loadRule(fileName string) error {
}
raw = nil

- if oldRule, found := l.rules[r.Name]; found {
- l.cleanListsRule(oldRule)
- }
-
+ // Rule files bypass the UI paths that validate names; refuse (but leave
+ // on disk) a file whose name could spoof a reserved name or forge logs.
+ if err = ValidName(r.Name); err != nil {
+ log.Warning("Refusing to load rule file %q: %s", fileName, err)
+ return fmt.Errorf("Refusing rule file %q: %s", fileName, err)
}

+ // Lists monitors start only once the rule is committed below, so a
+ // failed compile leaves the old rule (and its list) intact.
+ deferListsMonitors(&r)
if !r.Enabled {
// XXX: we only parse and load the Data field if the rule is disabled and the Data field is not empty
// the rule will remain disabled.
@@ -260,15 +272,25 @@ func (l *Loader) loadRule(fileName string) error {
@@ -260,15 +279,25 @@ func (l *Loader) loadRule(fileName string) error {
}
}
if oldRule, found := l.rules[r.Name]; found {
Expand All @@ -1183,7 +1188,7 @@ index 3d743556..d6d6e75b 100644
}

return nil
@@ -291,8 +313,12 @@ func (l *Loader) deleteRule(filePath string) {
@@ -291,8 +320,12 @@ func (l *Loader) deleteRule(filePath string) {
}

func (l *Loader) deleteRuleFromDisk(ruleName string) error {
Expand All @@ -1198,7 +1203,7 @@ index 3d743556..d6d6e75b 100644
}

// deleteOldRuleFromDisk deletes a rule from disk if the Duration changes
@@ -306,15 +332,15 @@ func (l *Loader) deleteOldRuleFromDisk(oldRule, newRule *Rule) {
@@ -306,15 +339,15 @@ func (l *Loader) deleteOldRuleFromDisk(oldRule, newRule *Rule) {
}

// cleanListsRule erases the lists loaded of an Operator of type Lists,
Expand All @@ -1218,7 +1223,7 @@ index 3d743556..d6d6e75b 100644
}
}
}
@@ -329,10 +355,9 @@ func (l *Loader) isUniqueName(name string) bool {
@@ -329,10 +362,9 @@ func (l *Loader) isUniqueName(name string) bool {
return !found
}

Expand All @@ -1231,7 +1236,7 @@ index 3d743556..d6d6e75b 100644
idx := 1
base := rule.Name
for l.isUniqueName(rule.Name) == false {
@@ -377,32 +402,27 @@ func (l *Loader) sortRules() {
@@ -377,32 +409,27 @@ func (l *Loader) sortRules() {
sort.Strings(l.activeRules)
}

Expand Down Expand Up @@ -1276,7 +1281,7 @@ index 3d743556..d6d6e75b 100644

if rule.Enabled {
if err := rule.Operator.Compile(); err != nil {
@@ -420,19 +440,44 @@ func (l *Loader) replaceUserRule(rule *Rule) (err error) {
@@ -420,19 +447,44 @@ func (l *Loader) replaceUserRule(rule *Rule) (err error) {
}
}
l.Lock()
Expand Down Expand Up @@ -1324,7 +1329,7 @@ index 3d743556..d6d6e75b 100644
if err != nil {
return err
}
@@ -441,13 +486,14 @@ func (l *Loader) scheduleTemporaryRule(rule Rule) error {
@@ -441,13 +493,14 @@ func (l *Loader) scheduleTemporaryRule(rule Rule) error {
l.Lock()
defer l.Unlock()

Expand Down Expand Up @@ -3709,6 +3714,144 @@ index 00000000..48d08fcf
+ t.Fatalf("acceptOrDeny should only release the slot with SetIsAsking(false), found %d calls", len(checks))
+ }
+}
diff --git a/daemon/rule/loader_name_validation_test.go b/daemon/rule/loader_name_validation_test.go
new file mode 100644
index 00000000..32b74df7
--- /dev/null
+++ b/daemon/rule/loader_name_validation_test.go
@@ -0,0 +1,132 @@
+package rule
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func ruleFileJSON(t *testing.T, name string) []byte {
+ t.Helper()
+ raw, err := json.Marshal(map[string]interface{}{
+ "name": name,
+ "enabled": true,
+ "precedence": false,
+ "action": "allow",
+ "duration": "always",
+ "operator": map[string]interface{}{
+ "type": "simple",
+ "operand": "process.path",
+ "sensitive": false,
+ "data": "/usr/bin/true",
+ "list": []interface{}{},
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return raw
+}
+
+func hasRule(l *Loader, name string) bool {
+ l.RLock()
+ defer l.RUnlock()
+ _, ok := l.rules[name]
+ return ok
+}
+
+// Rule files are written by root, but they bypass the UI paths that call
+// ValidName; the reserved name "" must not be loadable from disk.
+func TestLoadRefusesInvalidRuleNamesFromDisk(t *testing.T) {
+ bad := map[string]string{
+ "empty": "",
+ "slash": "a/b",
+ "newline": "a\nb",
+ "too-long": longName(),
+ "dot": ".",
+ "backslash": `a\b`,
+ }
+ dir := t.TempDir()
+ for file, name := range bad {
+ if err := os.WriteFile(filepath.Join(dir, file+".json"), ruleFileJSON(t, name), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ good := filepath.Join(dir, "good.json")
+ if err := os.WriteFile(good, ruleFileJSON(t, "good"), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ l, err := NewLoader(false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := l.Load(dir); err != nil {
+ t.Fatal(err)
+ }
+ if !hasRule(l, "good") {
+ t.Error("valid rule file was not loaded")
+ }
+ if n := l.NumRules(); n != 1 {
+ t.Errorf("only the valid rule should load, got %d rules", n)
+ }
+ for file, name := range bad {
+ if hasRule(l, name) {
+ t.Errorf("rule file %s.json with invalid name %q was loaded", file, name)
+ }
+ if err := l.loadRule(filepath.Join(dir, file+".json")); err == nil {
+ t.Errorf("loadRule(%s.json) accepted invalid name %q", file, name)
+ }
+ }
+ for file := range bad {
+ if _, err := os.Stat(filepath.Join(dir, file+".json")); err != nil {
+ t.Errorf("refused file %s.json must be left on disk: %s", file, err)
+ }
+ }
+}
+
+func longName() string {
+ b := make([]byte, maxNameBytes+1)
+ for i := range b {
+ b[i] = 'a'
+ }
+ return string(b)
+}
+
+func TestLiveReloadRefusesInvalidRuleName(t *testing.T) {
+ dir := t.TempDir()
+ l, err := NewLoader(true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := l.Load(dir); err != nil {
+ t.Fatal(err)
+ }
+ // wait for the watcher to be registered
+ time.Sleep(time.Second)
+
+ for file, name := range map[string]string{"empty": "", "slash": "a/b", "newline": "a\nb"} {
+ if err := os.WriteFile(filepath.Join(dir, file+".json"), ruleFileJSON(t, name), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // events are handled in order, so once the sentinel is in, the bad ones were processed
+ if err := os.WriteFile(filepath.Join(dir, "sentinel.json"), ruleFileJSON(t, "sentinel"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ deadline := time.Now().Add(5 * time.Second)
+ for !hasRule(l, "sentinel") && time.Now().Before(deadline) {
+ time.Sleep(50 * time.Millisecond)
+ }
+ if !hasRule(l, "sentinel") {
+ t.Fatal("live reload did not pick up the valid rule")
+ }
+ if n := l.NumRules(); n != 1 {
+ t.Errorf("live reload loaded a rule with an invalid name; %d rules", n)
+ }
+ if hasRule(l, "") {
+ t.Error("rule named \"\" loaded on live reload")
+ }
+}
diff --git a/daemon/rule/name.go b/daemon/rule/name.go
new file mode 100644
index 00000000..753b883d
Expand Down
2 changes: 1 addition & 1 deletion build_files/firewall/snitchwatch-system-daemon-verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
CANDIDATE = ROOT + 'opensnitchd'
BINARY = '/usr/bin/opensnitchd'
VERIFIER = '/usr/libexec/snitchwatch/verify-system-daemon.py'
EXPECTED_PINS_SHA256 = 'c66952cf513c5b750d3d57787be8b48dc190632d18407f254c2fe858b331d5b1'
EXPECTED_PINS_SHA256 = 'b13c15e8eb61d69add3e29345b1c109e5c16dde60541b13d93df0550456271f8'
REQUIRED = {PINS, PATCH, CANDIDATE, VERIFIER, ROOT + 'native-build-provenance.json', ROOT + 'rpm-inventory.txt', ROOT + 'go-build-info.txt',
ROOT + 'generated/ui.pb.go', ROOT + 'generated/ui_grpc.pb.go', ROOT + 'source/go.mod', ROOT + 'source/go.sum', ROOT + 'source/ui.proto',
'/usr/share/licenses/snitchwatch-opensnitchd/LICENSE', '/usr/share/licenses/snitchwatch-opensnitchd/THIRD-PARTY-LICENSES.md'}
Expand Down
3 changes: 3 additions & 0 deletions docs/research/snitchwatch-system-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,9 @@ Open items:
is used because rule-name validation refuses it, while `<default>` is a
legal rule name. The Event ring also no longer evicts an older Event for a
miss that records nothing (a change from upstream).
The rule loader also refuses rule files whose name fails the same
validation, at startup and on live reload (logged, file left in place),
so a root-written file named `""` cannot pose as that synthetic rule.
- ~~The daemon factory and CI do not run the patch's Go tests.~~ Fixed
2026-10-07: `just test-snitchwatch-daemon-patch` and the path-filtered
`snitchwatch-daemon-patch.yml` workflow run them, `-race` included.
Expand Down
2 changes: 1 addition & 1 deletion tests/test-snitchwatch-image-build-daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def test_production_pin_and_reviewed_patch_bytes(self):
self.assertEqual(sha(pins_bytes), real['EXPECTED_PINS_SHA256'])
pins = json.loads(pins_bytes)
self.assertEqual(sha((FACTORY / 'snitchwatch-system-daemon-shutdown.patch').read_bytes()), pins['patchSha256'])
self.assertEqual(len(pins['patchedFiles']), 49)
self.assertEqual(len(pins['patchedFiles']), 50)

def test_valid_complete_readonly_default_check(self):
before = {str(p): p.read_bytes() for p in self.root.rglob('*') if p.is_file()}
Expand Down