implement certificate posture check

This commit is contained in:
pascal
2026-08-31 13:47:55 +02:00
parent 086d8ba507
commit 84d83fa05e
34 changed files with 2825 additions and 1236 deletions
+6 -1
View File
@@ -173,6 +173,7 @@ type PeerSystemMeta struct { //nolint:revive
Environment Environment `gorm:"serializer:json"`
Flags Flags `gorm:"serializer:json"`
Files []File `gorm:"serializer:json"`
Certificates []string `gorm:"serializer:json"`
Capabilities []int32 `gorm:"serializer:json"`
SyncMessageVersion int
}
@@ -198,7 +199,8 @@ func (p PeerSystemMeta) isEmpty() bool {
p.SystemManufacturer == "" &&
p.Environment.Cloud == "" &&
p.Environment.Platform == "" &&
len(p.Files) == 0
len(p.Files) == 0 &&
len(p.Certificates) == 0
}
// AddedWithSSOLogin indicates whether this peer has been added with an SSO login by a user.
@@ -417,6 +419,9 @@ func diffMeta(oldMeta, newMeta PeerSystemMeta, oldLocation, newLocation Location
if !sameMultiset(oldMeta.Files, newMeta.Files) {
add("files", fmt.Sprintf("%v", oldMeta.Files), fmt.Sprintf("%v", newMeta.Files))
}
if !sameMultiset(oldMeta.Certificates, newMeta.Certificates) {
add("certificates", len(oldMeta.Certificates), len(newMeta.Certificates))
}
if oldMeta.SyncMessageVersion != newMeta.SyncMessageVersion {
add("sync_meta_version", fmt.Sprintf("%d", oldMeta.SyncMessageVersion), fmt.Sprintf("%d", newMeta.SyncMessageVersion))
}
+36
View File
@@ -0,0 +1,36 @@
package posture
import (
"context"
"fmt"
"time"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/shared/management/certposture"
)
// CertificateCheck passes when the peer holds a certificate, proven at meta ingestion,
// that chains to one of the configured PEM encoded CA certificates.
type CertificateCheck struct {
CACertificates []string
}
var _ Check = (*CertificateCheck)(nil)
func (c *CertificateCheck) Check(_ context.Context, peer nbpeer.Peer) (bool, error) {
return certposture.AnyChainMatchesCAs(peer.Meta.Certificates, c.CACertificates, time.Now()), nil
}
func (c *CertificateCheck) Name() string {
return CertificateCheckName
}
func (c *CertificateCheck) Validate() error {
if len(c.CACertificates) == 0 {
return fmt.Errorf("%s ca certificates shouldn't be empty", c.Name())
}
if _, err := certposture.ParseCAs(c.CACertificates); err != nil {
return fmt.Errorf("%s: %w", c.Name(), err)
}
return nil
}
@@ -0,0 +1,68 @@
package posture
import (
"context"
"crypto/x509"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
func TestCertificateCheck_Check(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
otherCA := certtest.NewCA(t, "other-root")
chain := certposture.EncodeChainPEM([]*x509.Certificate{ca.Issue(t, certtest.ECDSAKey(t), "device")})
tests := []struct {
name string
certificates []string
cas []string
want bool
}{
{"chains to configured CA", []string{chain}, []string{ca.PEM}, true},
{"one of several CAs matches", []string{chain}, []string{otherCA.PEM, ca.PEM}, true},
{"unrelated CA", []string{chain}, []string{otherCA.PEM}, false},
{"no certificates proven", nil, []string{ca.PEM}, false},
{"garbage entry does not hide a valid one", []string{"garbage", chain}, []string{ca.PEM}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := CertificateCheck{CACertificates: tt.cas}
got, err := check.Check(context.Background(), peer.Peer{Meta: peer.PeerSystemMeta{Certificates: tt.certificates}})
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestCertificateCheck_Validate(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
assert.Error(t, (&CertificateCheck{}).Validate())
assert.Error(t, (&CertificateCheck{CACertificates: []string{"not a pem"}}).Validate())
assert.NoError(t, (&CertificateCheck{CACertificates: []string{ca.PEM}}).Validate())
}
func TestChecks_CertificateCheckRegistered(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
checks := &Checks{Name: "cert", Checks: ChecksDefinition{CertificateCheck: &CertificateCheck{CACertificates: []string{ca.PEM}}}}
require.NoError(t, checks.Validate())
require.Len(t, checks.GetChecks(), 1)
assert.Equal(t, CertificateCheckName, checks.GetChecks()[0].Name())
copied := checks.Copy()
checks.Checks.CertificateCheck.CACertificates[0] = "mutated"
assert.Equal(t, ca.PEM, copied.Checks.CertificateCheck.CACertificates[0])
api := checks.ToAPIResponse()
require.NotNil(t, api.Checks.CertificateCheck)
roundTrip, err := NewChecksFromAPIPostureCheck(*api)
require.NoError(t, err)
assert.Equal(t, checks.Checks.CertificateCheck.CACertificates, roundTrip.Checks.CertificateCheck.CACertificates)
}
+19
View File
@@ -19,6 +19,7 @@ const (
GeoLocationCheckName = "GeoLocationCheck"
PeerNetworkRangeCheckName = "PeerNetworkRangeCheck"
ProcessCheckName = "ProcessCheck"
CertificateCheckName = "CertificateCheck"
CheckActionAllow string = "allow"
CheckActionDeny string = "deny"
@@ -61,6 +62,7 @@ type ChecksDefinition struct {
GeoLocationCheck *GeoLocationCheck `json:",omitempty"`
PeerNetworkRangeCheck *PeerNetworkRangeCheck `json:",omitempty"`
ProcessCheck *ProcessCheck `json:",omitempty"`
CertificateCheck *CertificateCheck `json:",omitempty"`
}
// Copy returns a copy of a checks definition.
@@ -113,6 +115,12 @@ func (cd ChecksDefinition) Copy() ChecksDefinition {
}
copy(cdCopy.ProcessCheck.Processes, processCheck.Processes)
}
if cd.CertificateCheck != nil {
cdCopy.CertificateCheck = &CertificateCheck{
CACertificates: make([]string, len(cd.CertificateCheck.CACertificates)),
}
copy(cdCopy.CertificateCheck.CACertificates, cd.CertificateCheck.CACertificates)
}
return cdCopy
}
@@ -157,6 +165,9 @@ func (pc *Checks) GetChecks() []Check {
if pc.Checks.ProcessCheck != nil {
checks = append(checks, pc.Checks.ProcessCheck)
}
if pc.Checks.CertificateCheck != nil {
checks = append(checks, pc.Checks.CertificateCheck)
}
return checks
}
@@ -212,6 +223,10 @@ func buildPostureCheck(postureChecksID string, name string, description string,
postureChecks.Checks.ProcessCheck = toProcessCheck(processCheck)
}
if certificateCheck := checks.CertificateCheck; certificateCheck != nil {
postureChecks.Checks.CertificateCheck = &CertificateCheck{CACertificates: certificateCheck.CaCertificates}
}
return &postureChecks, nil
}
@@ -246,6 +261,10 @@ func (pc *Checks) ToAPIResponse() *api.PostureCheck {
checks.ProcessCheck = toProcessCheckResponse(pc.Checks.ProcessCheck)
}
if pc.Checks.CertificateCheck != nil {
checks.CertificateCheck = &api.CertificateCheck{CaCertificates: pc.Checks.CertificateCheck.CACertificates}
}
return &api.PostureCheck{
Id: pc.ID,
Name: pc.Name,
+6 -3
View File
@@ -1896,7 +1896,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
inactivity_expiration_enabled, last_login, created_at, ephemeral, extra_dns_labels, allow_extra_dns_labels, meta_hostname,
meta_go_os, meta_kernel, meta_core, meta_platform, meta_os, meta_os_version, meta_wt_version, meta_ui_version,
meta_kernel_version, meta_network_addresses, meta_system_serial_number, meta_system_product_name, meta_system_manufacturer,
meta_environment, meta_flags, meta_files, meta_capabilities, peer_status_last_seen, peer_status_session_started_at,
meta_environment, meta_flags, meta_files, meta_certificates, meta_capabilities, peer_status_last_seen, peer_status_session_started_at,
peer_status_connected, peer_status_login_expired, peer_status_requires_approval, location_connection_ip,
location_country_code, location_city_name, location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster, ipv6, meta_sync_message_version
FROM peers WHERE account_id = $1`
@@ -1914,7 +1914,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
peerStatusLastSeen sql.NullTime
peerStatusSessionStartedAt sql.NullInt64
peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval, proxyEmbedded sql.NullBool
ip, extraDNS, netAddr, env, flags, files, capabilities, connIP, ipv6 []byte
ip, extraDNS, netAddr, env, flags, files, certificates, capabilities, connIP, ipv6 []byte
metaHostname, metaGoOS, metaKernel, metaCore, metaPlatform sql.NullString
metaOS, metaOSVersion, metaWtVersion, metaUIVersion, metaKernelVersion sql.NullString
metaSystemSerialNumber, metaSystemProductName, metaSystemManufacturer sql.NullString
@@ -1927,7 +1927,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
&loginExpirationEnabled, &inactivityExpirationEnabled, &lastLogin, &createdAt, &ephemeral, &extraDNS,
&allowExtraDNSLabels, &metaHostname, &metaGoOS, &metaKernel, &metaCore, &metaPlatform,
&metaOS, &metaOSVersion, &metaWtVersion, &metaUIVersion, &metaKernelVersion, &netAddr,
&metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &capabilities,
&metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &certificates, &capabilities,
&peerStatusLastSeen, &peerStatusSessionStartedAt, &peerStatusConnected, &peerStatusLoginExpired,
&peerStatusRequiresApproval, &connIP, &locationCountryCode, &locationCityName, &locationGeoNameID,
&proxyEmbedded, &proxyCluster, &ipv6, &metaSyncMessageVersion)
@@ -2044,6 +2044,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
if files != nil {
_ = json.Unmarshal(files, &p.Meta.Files)
}
if certificates != nil {
_ = json.Unmarshal(certificates, &p.Meta.Certificates)
}
if capabilities != nil {
_ = json.Unmarshal(capabilities, &p.Meta.Capabilities)
}
+1 -1
View File
@@ -573,7 +573,7 @@ func TestSqlStore_SavePeer(t *testing.T) {
numOfFields, err := populateFields.PopulateAll(reflectedMetadata)
assert.NoError(t, err)
assert.Equal(t, 32, numOfFields)
assert.Equal(t, 33, numOfFields)
// save status of non-existing peer
peer := &nbpeer.Peer{
@@ -194,6 +194,7 @@ func twinPeer(p *nbpeer.Peer) *nmdata.Peer {
KernelVersion: p.Meta.KernelVersion,
NetworkAddresses: networkAddresses,
Files: files,
Certificates: p.Meta.Certificates,
Capabilities: p.Meta.Capabilities,
SyncMessageVersion: p.Meta.SyncMessageVersion,
Flags: nmdata.Flags{
@@ -449,6 +450,9 @@ func TwinPostureChecks(pc *posture.Checks) *nmdata.PostureChecks {
}
out.Checks.ProcessCheck = &nmdata.ProcessCheck{Processes: procs}
}
if def.CertificateCheck != nil {
out.Checks.CertificateCheck = &nmdata.CertificateCheck{CACertificates: def.CertificateCheck.CACertificates}
}
return out
}