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
6 changes: 5 additions & 1 deletion pkg/apis/softwarecomposition/network_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ type NetworkNeighbor struct {
Ports []NetworkPort
PodSelector *metav1.LabelSelector
NamespaceSelector *metav1.LabelSelector
IPAddress string
IPAddress string // DEPRECATED - use IPAddresses instead.
// IPAddresses is the v0.0.2 list-form replacement for IPAddress.
// Each entry MAY be a literal IP, a CIDR (a.b.c.d/n), or the "*" sentinel.
// See pkg/registry/file/networkmatch for matcher semantics.
IPAddresses []string
}

type NetworkPort struct {
Expand Down
48 changes: 48 additions & 0 deletions pkg/apis/softwarecomposition/v1beta1/generated.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions pkg/apis/softwarecomposition/v1beta1/generated.proto

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion pkg/apis/softwarecomposition/v1beta1/network_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ type NetworkNeighbor struct {
Ports []NetworkPort `json:"ports" patchStrategy:"merge" patchMergeKey:"name" protobuf:"bytes,5,rep,name=ports"`
PodSelector *metav1.LabelSelector `json:"podSelector" protobuf:"bytes,6,req,name=podSelector"`
NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector" protobuf:"bytes,7,req,name=namespaceSelector"`
IPAddress string `json:"ipAddress" protobuf:"bytes,8,req,name=ipAddress"`
IPAddress string `json:"ipAddress" protobuf:"bytes,8,req,name=ipAddress"` // DEPRECATED - use IPAddresses instead.
// IPAddresses is the v0.0.2 list-form replacement for IPAddress.
// Each entry MAY be a literal IP, a CIDR (a.b.c.d/n), or the "*" sentinel.
// See pkg/registry/file/networkmatch for matcher semantics.
IPAddresses []string `json:"ipAddresses,omitempty" protobuf:"bytes,9,rep,name=ipAddresses"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: IPAddresses is added here, but the generated protobuf/conversion/deepcopy code is unchanged, so the field is silently dropped on real storage paths. The new TestNetworkNeighbor_IPAddresses_ProtobufRoundtrip already fails (go test ./pkg/apis/softwarecomposition/v1beta1). Please regenerate generated.pb.go, conversion code, and deepcopy code for this field before merge.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has now been addressed and the PR compiles standalone

}

type NetworkPort struct {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package v1beta1

import (
"reflect"
"testing"
)

// TestNetworkNeighbor_IPAddresses_ProtobufRoundtrip pins the v0.0.2
// protobuf wire contract for the new IPAddresses field. Storage persists
// NetworkNeighborhood objects to etcd via this protobuf encoding; if
// the field is dropped on round-trip, the spec field is silently lost
// and runtime matchers see an empty list.
//
// Protobuf field number 9 (declared on the struct tag) MUST be preserved
// across Marshal → Unmarshal.
func TestNetworkNeighbor_IPAddresses_ProtobufRoundtrip(t *testing.T) {
original := &NetworkNeighbor{
Identifier: "test-entry",
Type: "external",
IPAddress: "10.1.2.3", // deprecated singular still works
IPAddresses: []string{"10.0.0.0/8", "192.168.0.0/16", "*", "2001:db8::/32"},
DNSNames: []string{"api.stripe.com.", "*.stripe.com."},
}

wire, err := original.Marshal()
if err != nil {
t.Fatalf("Marshal: %v", err)
}

decoded := &NetworkNeighbor{}
if err := decoded.Unmarshal(wire); err != nil {
t.Fatalf("Unmarshal: %v", err)
}

if !reflect.DeepEqual(decoded.IPAddresses, original.IPAddresses) {
t.Errorf("IPAddresses roundtrip mismatch:\n got: %v\n want: %v",
decoded.IPAddresses, original.IPAddresses)
}

// Sanity: existing fields still survive (no regression).
if decoded.IPAddress != original.IPAddress {
t.Errorf("deprecated IPAddress lost: got %q want %q", decoded.IPAddress, original.IPAddress)
}
if !reflect.DeepEqual(decoded.DNSNames, original.DNSNames) {
t.Errorf("DNSNames lost: got %v want %v", decoded.DNSNames, original.DNSNames)
}
}

// TestNetworkNeighbor_IPAddresses_EmptyOmitted confirms that an empty
// IPAddresses slice is not encoded on the wire (zero overhead for
// existing profiles that don't use the new field).
func TestNetworkNeighbor_IPAddresses_EmptyOmitted(t *testing.T) {
withField := &NetworkNeighbor{
Identifier: "id",
Type: "external",
IPAddresses: nil,
}
withoutField := &NetworkNeighbor{
Identifier: "id",
Type: "external",
}
a, err := withField.Marshal()
if err != nil {
t.Fatalf("Marshal(withField): %v", err)
}
b, err := withoutField.Marshal()
if err != nil {
t.Fatalf("Marshal(withoutField): %v", err)
}
if !reflect.DeepEqual(a, b) {
t.Errorf("nil IPAddresses must encode identically to absent field;\n got %d bytes vs %d bytes",
len(a), len(b))
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions pkg/apis/softwarecomposition/v1beta1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions pkg/apis/softwarecomposition/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 74 additions & 0 deletions pkg/registry/file/networkmatch/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# networkmatch

Wildcard-aware matchers for the `NetworkNeighbor.IPAddresses` and
`NetworkNeighbor.DNSNames` fields, used by node-agent's CEL functions
`nn.was_address_in_{egress,ingress}` and `nn.is_domain_in_{egress,ingress}`.

This package is the runtime counterpart to the spec sections §5.7 (IP)
and §5.8 (DNS) at <https://billofbehavior.fusioncore.ai/bob/docs/drafts/spec-v0.0.2/>.

## Wildcard token vocabulary

Same tokens as the path / argv matchers in `dynamicpathdetector` — see
that package's `coverage_test.go` for the contract.

| Token | IP semantics | DNS semantics |
|---|---|---|
| Literal | byte-equality after canonicalization (net.IP) | byte-equality after trailing-dot normalization |
| CIDR (`a.b.c.d/n`) | `net.IPNet.Contains(observed)` | — |
| `*` as full entry | sugar for `0.0.0.0/0` ∪ `::/0` (any IP) | — |
| `*.<suffix>` (leading) | — | RFC 4592 — exactly one DNS label before `<suffix>` |
| `<a>.⋯.<b>` (mid) | — | DynamicIdentifier — exactly one DNS label between `<a>` and `<b>` |
| `<prefix>.*` (trailing) | — | one or more DNS labels after `<prefix>` (never zero) |
| `**` | reserved (rejected at admission) | reserved (rejected at admission) |

## API

```go
// MatchIP reports whether observedIP matches any of the profile entries.
// Each entry MAY be: a literal IP, a CIDR, or the "*" sentinel.
//
// observedIP is matched as text (the function calls net.ParseIP internally
// so the caller does not need to pre-parse it). Empty profile slice
// returns false (no entries → nothing to match against). Empty observedIP
// returns false (no observation to match).
//
// Compile-once contract: callers running this in a hot path SHOULD wrap
// it in a closure that captures pre-compiled *IPNet values across calls
// (the caller knows the profile's lifecycle, this function does not).
func MatchIP(profileEntries []string, observedIP string) bool

// MatchDNS reports whether observedName matches any of the profile entries.
// Each entry MAY use the wildcard tokens above.
//
// Both profile entries and observedName are normalized before
// comparison: a trailing dot is stripped if present, and labels are
// lowercased for case-insensitive equality.
func MatchDNS(profileEntries []string, observedName string) bool
```

## Performance contract

Both functions are called per network event from R0005 / R0011 / R1003 /
R1009. The benchmarks in `bench_test.go` track:

- `BenchmarkMatchIP_Literal` — baseline byte-equality
- `BenchmarkMatchIP_CIDR` — single CIDR match
- `BenchmarkMatchIP_LongMixedList` — 10-entry mixed list, observed IP not in list (worst case)
- `BenchmarkMatchDNS_Literal` — baseline
- `BenchmarkMatchDNS_LeadingWildcard` — RFC 4592
- `BenchmarkMatchDNS_DeepName` — 10-label observed name against a leading-`*` profile

Targets (CI runner reference):
- IP literal / CIDR: < 200 ns per call
- DNS literal: < 300 ns per call
- DNS wildcard: < 600 ns per call

Beat or hold these on every change; the matcher fires on every network
event captured by the eBPF tracers.

## Testing

`match_ip_test.go` and `match_dns_test.go` are the contract pinning. The
fixtures in `node-agent/tests/resources/network-wildcards/` are the
end-to-end examples; both layers MUST agree.
Loading
Loading