Show posture values and report history
Pass/fail was the main device UI signal, but operators need the agent's observed value. Expose a formatted value per check, show current postures on the device page, and replace the Postures tab with paginated report history grouped by agent push time. Status stays in the model for later rulesets. Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
@@ -39,6 +39,7 @@ type (
|
||||
TenantID gid.TenantID `db:"tenant_id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
DeviceID gid.GID `db:"device_id"`
|
||||
CorrelationID gid.GID `db:"correlation_id"`
|
||||
CheckKey string `db:"check_key"`
|
||||
Status DevicePostureStatus `db:"status"`
|
||||
Evidence json.RawMessage `db:"evidence"`
|
||||
@@ -86,6 +87,7 @@ INSERT INTO device_postures (
|
||||
tenant_id,
|
||||
organization_id,
|
||||
device_id,
|
||||
correlation_id,
|
||||
check_key,
|
||||
status,
|
||||
evidence,
|
||||
@@ -96,6 +98,7 @@ INSERT INTO device_postures (
|
||||
@tenant_id,
|
||||
@organization_id,
|
||||
@device_id,
|
||||
@correlation_id,
|
||||
@check_key,
|
||||
@status,
|
||||
@evidence,
|
||||
@@ -108,11 +111,12 @@ INSERT INTO device_postures (
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": p.OrganizationID,
|
||||
"device_id": p.DeviceID,
|
||||
"correlation_id": p.CorrelationID,
|
||||
"check_key": p.CheckKey,
|
||||
"status": p.Status,
|
||||
"evidence": evidence,
|
||||
"observed_at": observedAt,
|
||||
"created_at": p.CreatedAt,
|
||||
"created_at": now,
|
||||
}
|
||||
|
||||
if _, err := conn.Exec(ctx, q, args); err != nil {
|
||||
@@ -138,8 +142,9 @@ func normalizeObservedAt(observed, now time.Time) time.Time {
|
||||
return observed
|
||||
}
|
||||
|
||||
// LoadLatestByDeviceID loads a page of the latest posture row for each
|
||||
// check_key on the given device.
|
||||
// LoadLatestByDeviceID loads the latest posture per check_key by observed_at.
|
||||
// A check that errored in the last run keeps its last known result, so the set
|
||||
// can span multiple reports.
|
||||
func (p *DevicePostures) LoadLatestByDeviceID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
@@ -154,6 +159,7 @@ WITH latest AS (
|
||||
tenant_id,
|
||||
organization_id,
|
||||
device_id,
|
||||
correlation_id,
|
||||
check_key,
|
||||
status,
|
||||
evidence,
|
||||
@@ -189,6 +195,64 @@ SELECT * FROM latest WHERE %s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *DevicePostures) LoadByDeviceIDAndCorrelationIDs(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
deviceID gid.GID,
|
||||
correlationIDs []gid.GID,
|
||||
) error {
|
||||
if len(correlationIDs) == 0 {
|
||||
*p = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
device_id,
|
||||
correlation_id,
|
||||
check_key,
|
||||
status,
|
||||
evidence,
|
||||
observed_at,
|
||||
created_at
|
||||
FROM
|
||||
device_postures
|
||||
WHERE
|
||||
%s
|
||||
AND device_id = @device_id
|
||||
AND correlation_id = ANY(@correlation_ids)
|
||||
ORDER BY
|
||||
created_at DESC,
|
||||
check_key ASC
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"device_id": deviceID,
|
||||
"correlation_ids": correlationIDs,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query device postures by correlation_id: %w", err)
|
||||
}
|
||||
|
||||
postures, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DevicePosture])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect device postures by correlation_id: %w", err)
|
||||
}
|
||||
|
||||
*p = postures
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadHistoryByDeviceIDAndCheckKey returns the most recent N entries for one
|
||||
// (device, check_key) pair, newest first.
|
||||
func (p *DevicePostures) LoadHistoryByDeviceIDAndCheckKey(
|
||||
@@ -209,6 +273,7 @@ SELECT
|
||||
tenant_id,
|
||||
organization_id,
|
||||
device_id,
|
||||
correlation_id,
|
||||
check_key,
|
||||
status,
|
||||
evidence,
|
||||
|
||||
145
pkg/coredata/device_posture_report.go
Normal file
145
pkg/coredata/device_posture_report.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
DevicePostureReport struct {
|
||||
ID gid.GID `db:"id"`
|
||||
DeviceID gid.GID `db:"device_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
Postures DevicePostures `db:"-"`
|
||||
}
|
||||
|
||||
DevicePostureReports []*DevicePostureReport
|
||||
)
|
||||
|
||||
func (s DevicePostureReport) CursorKey(
|
||||
orderBy DevicePostureReportOrderField,
|
||||
) page.CursorKey {
|
||||
switch orderBy {
|
||||
case DevicePostureReportOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(s.ID, s.CreatedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func (s *DevicePostureReports) LoadByDeviceID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
deviceID gid.GID,
|
||||
cursor *page.Cursor[DevicePostureReportOrderField],
|
||||
) error {
|
||||
q := `
|
||||
WITH reports AS (
|
||||
SELECT
|
||||
correlation_id AS id,
|
||||
device_id,
|
||||
MIN(created_at) AS created_at
|
||||
FROM
|
||||
device_postures
|
||||
WHERE
|
||||
%s
|
||||
AND device_id = @device_id
|
||||
GROUP BY
|
||||
device_id,
|
||||
correlation_id
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
device_id,
|
||||
created_at
|
||||
FROM
|
||||
reports
|
||||
WHERE %s
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"device_id": deviceID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query device posture reports: %w", err)
|
||||
}
|
||||
|
||||
reports, err := pgx.CollectRows(
|
||||
rows,
|
||||
pgx.RowToAddrOfStructByName[DevicePostureReport],
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect device posture reports: %w", err)
|
||||
}
|
||||
|
||||
*s = reports
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DevicePostureReports) CountByDeviceID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
deviceID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM (
|
||||
SELECT
|
||||
correlation_id
|
||||
FROM
|
||||
device_postures
|
||||
WHERE
|
||||
%s
|
||||
AND device_id = @device_id
|
||||
GROUP BY
|
||||
correlation_id
|
||||
) AS reports
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"device_id": deviceID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot count device posture reports: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
84
pkg/coredata/device_posture_report_order_field.go
Normal file
84
pkg/coredata/device_posture_report_order_field.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type DevicePostureReportOrderField string
|
||||
|
||||
const (
|
||||
DevicePostureReportOrderFieldCreatedAt DevicePostureReportOrderField = "CREATED_AT"
|
||||
)
|
||||
|
||||
var (
|
||||
_ page.OrderField = DevicePostureReportOrderField("")
|
||||
_ fmt.Stringer = DevicePostureReportOrderField("")
|
||||
_ encoding.TextMarshaler = DevicePostureReportOrderField("")
|
||||
_ encoding.TextUnmarshaler = (*DevicePostureReportOrderField)(nil)
|
||||
)
|
||||
|
||||
func DevicePostureReportOrderFields() []DevicePostureReportOrderField {
|
||||
return []DevicePostureReportOrderField{
|
||||
DevicePostureReportOrderFieldCreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (v DevicePostureReportOrderField) IsValid() bool {
|
||||
switch v {
|
||||
case DevicePostureReportOrderFieldCreatedAt:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (v DevicePostureReportOrderField) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
func (v DevicePostureReportOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(v.String()), nil
|
||||
}
|
||||
|
||||
func (v *DevicePostureReportOrderField) UnmarshalText(text []byte) error {
|
||||
val := DevicePostureReportOrderField(text)
|
||||
if !val.IsValid() {
|
||||
return fmt.Errorf("invalid DevicePostureReportOrderField value: %q", string(text))
|
||||
}
|
||||
|
||||
*v = val
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f DevicePostureReportOrderField) Column() string {
|
||||
switch f {
|
||||
case DevicePostureReportOrderFieldCreatedAt:
|
||||
return "created_at"
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", f))
|
||||
}
|
||||
322
pkg/coredata/device_posture_report_test.go
Normal file
322
pkg/coredata/device_posture_report_test.go
Normal file
@@ -0,0 +1,322 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
package coredata_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/internal/test"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
func insertDevicePostureWithEvidence(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
client *pg.Client,
|
||||
fx devicePostureFixture,
|
||||
checkKey string,
|
||||
status coredata.DevicePostureStatus,
|
||||
evidence map[string]any,
|
||||
correlationID gid.GID,
|
||||
createdAt time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
raw, err := json.Marshal(evidence)
|
||||
require.NoError(t, err)
|
||||
|
||||
posture := coredata.DevicePosture{
|
||||
ID: gid.New(fx.scope.GetTenantID(), coredata.DevicePostureEntityType),
|
||||
OrganizationID: fx.organizationID,
|
||||
DeviceID: fx.deviceID,
|
||||
CorrelationID: correlationID,
|
||||
CheckKey: checkKey,
|
||||
Status: status,
|
||||
Evidence: raw,
|
||||
ObservedAt: createdAt,
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return posture.Insert(ctx, tx, fx.scope)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestDevicePostureReport_LoadByDeviceID_GroupsByCorrelationID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
fx := seedDevicePostureFixture(t, ctx, client)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
older := now.Add(-time.Hour)
|
||||
newer := now
|
||||
olderCorr := gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType)
|
||||
newerCorr := gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType)
|
||||
|
||||
insertDevicePostureWithEvidence(
|
||||
t, ctx, client, fx,
|
||||
"OS_VERSION",
|
||||
coredata.DevicePostureStatusPass,
|
||||
map[string]any{"product_version": "14.0"},
|
||||
olderCorr,
|
||||
older,
|
||||
)
|
||||
insertDevicePostureWithEvidence(
|
||||
t, ctx, client, fx,
|
||||
"DISK_ENCRYPTION",
|
||||
coredata.DevicePostureStatusPass,
|
||||
map[string]any{"raw": "FileVault is On."},
|
||||
olderCorr,
|
||||
older,
|
||||
)
|
||||
insertDevicePostureWithEvidence(
|
||||
t, ctx, client, fx,
|
||||
"OS_VERSION",
|
||||
coredata.DevicePostureStatusPass,
|
||||
map[string]any{"product_version": "15.4"},
|
||||
newerCorr,
|
||||
newer,
|
||||
)
|
||||
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
orderBy := page.OrderBy[coredata.DevicePostureReportOrderField]{
|
||||
Field: coredata.DevicePostureReportOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
}
|
||||
cursor := page.NewCursor(10, nil, page.Head, orderBy)
|
||||
|
||||
var reports coredata.DevicePostureReports
|
||||
require.NoError(t, reports.LoadByDeviceID(ctx, conn, fx.scope, fx.deviceID, cursor))
|
||||
|
||||
p := page.NewPage(reports, cursor)
|
||||
require.Len(t, p.Data, 2)
|
||||
assert.Equal(t, newerCorr, p.Data[0].ID)
|
||||
assert.Equal(t, olderCorr, p.Data[1].ID)
|
||||
assert.True(t, p.Data[0].CreatedAt.Equal(newer))
|
||||
assert.True(t, p.Data[1].CreatedAt.Equal(older))
|
||||
|
||||
correlationIDs := []gid.GID{p.Data[0].ID, p.Data[1].ID}
|
||||
|
||||
var postures coredata.DevicePostures
|
||||
require.NoError(t, postures.LoadByDeviceIDAndCorrelationIDs(
|
||||
ctx, conn, fx.scope, fx.deviceID, correlationIDs,
|
||||
))
|
||||
require.Len(t, postures, 3)
|
||||
|
||||
var counter coredata.DevicePostureReports
|
||||
|
||||
count, err := counter.CountByDeviceID(ctx, conn, fx.scope, fx.deviceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
func TestDevicePostureReport_LoadByDeviceID_IDIsCorrelationID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
fx := seedDevicePostureFixture(t, ctx, client)
|
||||
|
||||
createdAt := time.Now().UTC().Truncate(time.Microsecond)
|
||||
correlationID := gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType)
|
||||
|
||||
insertDevicePostureWithEvidence(
|
||||
t, ctx, client, fx,
|
||||
"OS_VERSION",
|
||||
coredata.DevicePostureStatusPass,
|
||||
map[string]any{"product_version": "15.4"},
|
||||
correlationID,
|
||||
createdAt,
|
||||
)
|
||||
insertDevicePostureWithEvidence(
|
||||
t, ctx, client, fx,
|
||||
"DISK_ENCRYPTION",
|
||||
coredata.DevicePostureStatusPass,
|
||||
map[string]any{"raw": "FileVault is On."},
|
||||
correlationID,
|
||||
createdAt,
|
||||
)
|
||||
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
reports := loadDevicePostureReports(t, ctx, conn, fx, 10, nil)
|
||||
require.Len(t, reports, 1)
|
||||
|
||||
report := reports[0]
|
||||
assert.Equal(t, correlationID, report.ID)
|
||||
|
||||
var postures coredata.DevicePostures
|
||||
require.NoError(t, postures.LoadByDeviceIDAndCorrelationIDs(
|
||||
ctx, conn, fx.scope, fx.deviceID, []gid.GID{report.ID},
|
||||
))
|
||||
require.Len(t, postures, 2)
|
||||
|
||||
for _, posture := range postures {
|
||||
assert.Equal(t, correlationID, posture.CorrelationID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
func TestDevicePostureReport_LoadByDeviceID_PaginatesAcrossPages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
fx := seedDevicePostureFixture(t, ctx, client)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
createdAts := []time.Time{
|
||||
now.Add(-2 * time.Hour),
|
||||
now.Add(-time.Hour),
|
||||
now,
|
||||
}
|
||||
|
||||
correlationIDs := make([]gid.GID, len(createdAts))
|
||||
for i := range createdAts {
|
||||
correlationIDs[i] = gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType)
|
||||
}
|
||||
|
||||
for i, createdAt := range createdAts {
|
||||
insertDevicePostureWithEvidence(
|
||||
t, ctx, client, fx,
|
||||
"OS_VERSION",
|
||||
coredata.DevicePostureStatusPass,
|
||||
map[string]any{"product_version": fmt.Sprintf("15.%d", i)},
|
||||
correlationIDs[i],
|
||||
createdAt,
|
||||
)
|
||||
insertDevicePostureWithEvidence(
|
||||
t, ctx, client, fx,
|
||||
"DISK_ENCRYPTION",
|
||||
coredata.DevicePostureStatusPass,
|
||||
map[string]any{"raw": "FileVault is On."},
|
||||
correlationIDs[i],
|
||||
createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
orderBy := page.OrderBy[coredata.DevicePostureReportOrderField]{
|
||||
Field: coredata.DevicePostureReportOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
}
|
||||
|
||||
first := loadDevicePostureReports(t, ctx, conn, fx, 2, nil)
|
||||
require.Len(t, first, 2)
|
||||
assert.Equal(t, correlationIDs[2], first[0].ID)
|
||||
assert.Equal(t, correlationIDs[1], first[1].ID)
|
||||
assert.True(t, first[0].CreatedAt.Equal(createdAts[2]))
|
||||
assert.True(t, first[1].CreatedAt.Equal(createdAts[1]))
|
||||
|
||||
after := first[1].CursorKey(orderBy.Field)
|
||||
|
||||
second := loadDevicePostureReports(t, ctx, conn, fx, 2, &after)
|
||||
require.Len(t, second, 1)
|
||||
assert.Equal(t, correlationIDs[0], second[0].ID)
|
||||
assert.True(t, second[0].CreatedAt.Equal(createdAts[0]))
|
||||
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
func TestDevicePostureReport_LoadByDeviceID_IsTenantScoped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
fx := seedDevicePostureFixture(t, ctx, client)
|
||||
other := seedDevicePostureFixture(t, ctx, client)
|
||||
|
||||
createdAt := time.Now().UTC().Truncate(time.Microsecond)
|
||||
correlationID := gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType)
|
||||
|
||||
insertDevicePostureWithEvidence(
|
||||
t, ctx, client, fx,
|
||||
"OS_VERSION",
|
||||
coredata.DevicePostureStatusPass,
|
||||
map[string]any{"product_version": "15.4"},
|
||||
correlationID,
|
||||
createdAt,
|
||||
)
|
||||
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
orderBy := page.OrderBy[coredata.DevicePostureReportOrderField]{
|
||||
Field: coredata.DevicePostureReportOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
}
|
||||
cursor := page.NewCursor(10, nil, page.Head, orderBy)
|
||||
|
||||
var reports coredata.DevicePostureReports
|
||||
require.NoError(t, reports.LoadByDeviceID(
|
||||
ctx, conn, other.scope, fx.deviceID, cursor,
|
||||
))
|
||||
assert.Empty(t, reports)
|
||||
|
||||
count, err := reports.CountByDeviceID(ctx, conn, other.scope, fx.deviceID)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, count)
|
||||
|
||||
var postures coredata.DevicePostures
|
||||
require.NoError(t, postures.LoadByDeviceIDAndCorrelationIDs(
|
||||
ctx, conn, other.scope, fx.deviceID, []gid.GID{correlationID},
|
||||
))
|
||||
assert.Empty(t, postures)
|
||||
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
func loadDevicePostureReports(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
fx devicePostureFixture,
|
||||
size int,
|
||||
from *page.CursorKey,
|
||||
) coredata.DevicePostureReports {
|
||||
t.Helper()
|
||||
|
||||
orderBy := page.OrderBy[coredata.DevicePostureReportOrderField]{
|
||||
Field: coredata.DevicePostureReportOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
}
|
||||
cursor := page.NewCursor(size, from, page.Head, orderBy)
|
||||
|
||||
var reports coredata.DevicePostureReports
|
||||
require.NoError(t, reports.LoadByDeviceID(ctx, conn, fx.scope, fx.deviceID, cursor))
|
||||
|
||||
return page.NewPage(reports, cursor).Data
|
||||
}
|
||||
@@ -117,6 +117,7 @@ func insertDevicePosture(
|
||||
ID: gid.New(fx.scope.GetTenantID(), coredata.DevicePostureEntityType),
|
||||
OrganizationID: fx.organizationID,
|
||||
DeviceID: fx.deviceID,
|
||||
CorrelationID: gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType),
|
||||
CheckKey: checkKey,
|
||||
Status: status,
|
||||
ObservedAt: observedAt,
|
||||
|
||||
409
pkg/coredata/device_posture_value.go
Normal file
409
pkg/coredata/device_posture_value.go
Normal file
@@ -0,0 +1,409 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// devicePostureValueTextMax bounds a TEXT value. Evidence literals we surface
|
||||
// are short by nature (a version, an engine name, a few agent names); the cap
|
||||
// only guards against a pathological host.
|
||||
const devicePostureValueTextMax = 80
|
||||
|
||||
// DevicePostureValue is the observation a posture check made, in a shape a
|
||||
// client can localize. Status is deliberately absent: whether the observation
|
||||
// is acceptable is a ruleset decision, not a property of the measurement.
|
||||
type DevicePostureValue struct {
|
||||
Kind DevicePostureValueKind
|
||||
Text string
|
||||
Number *int
|
||||
}
|
||||
|
||||
// ParseDevicePostureValue derives the observed value of a posture check from
|
||||
// the evidence the agent recorded.
|
||||
//
|
||||
// Evidence shapes differ per platform and per tool, so each check dispatches on
|
||||
// the "backend" key the agent sets (or, where it sets none, on a distinctive
|
||||
// key). Unrecognised evidence yields UNKNOWN — never a guess, and never raw
|
||||
// command output, which can carry usernames and file paths.
|
||||
func ParseDevicePostureValue(
|
||||
checkKey string,
|
||||
evidence json.RawMessage,
|
||||
) DevicePostureValue {
|
||||
ev := decodeEvidenceMap(evidence)
|
||||
if len(ev) == 0 {
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
switch DevicePostureCheckKey(checkKey) {
|
||||
case DevicePostureCheckKeyOSVersion:
|
||||
return parseOSVersionValue(ev)
|
||||
case DevicePostureCheckKeyDiskEncryption:
|
||||
return parseDiskEncryptionValue(ev)
|
||||
case DevicePostureCheckKeyScreenLock:
|
||||
return parseScreenLockValue(ev)
|
||||
case DevicePostureCheckKeyFirewallEnabled:
|
||||
return parseFirewallValue(ev)
|
||||
case DevicePostureCheckKeyTimeSync:
|
||||
return parseTimeSyncValue(ev)
|
||||
case DevicePostureCheckKeyAutoUpdate:
|
||||
return parseAutoUpdateValue(ev)
|
||||
case DevicePostureCheckKeyPasswordPolicy:
|
||||
return parsePasswordPolicyValue(ev)
|
||||
case DevicePostureCheckKeyRemoteLogin:
|
||||
return parseRemoteLoginValue(ev)
|
||||
case DevicePostureCheckKeyMalwareProtection:
|
||||
return parseMalwareProtectionValue(ev)
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
func decodeEvidenceMap(evidence json.RawMessage) map[string]any {
|
||||
if len(evidence) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ev map[string]any
|
||||
if err := json.Unmarshal(evidence, &ev); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
func onOffValue(on bool) DevicePostureValue {
|
||||
if on {
|
||||
return DevicePostureValue{Kind: DevicePostureValueKindOn}
|
||||
}
|
||||
|
||||
return DevicePostureValue{Kind: DevicePostureValueKindOff}
|
||||
}
|
||||
|
||||
func unknownValue() DevicePostureValue {
|
||||
return DevicePostureValue{Kind: DevicePostureValueKindUnknown}
|
||||
}
|
||||
|
||||
func noneValue() DevicePostureValue {
|
||||
return DevicePostureValue{Kind: DevicePostureValueKindNone}
|
||||
}
|
||||
|
||||
func configuredValue() DevicePostureValue {
|
||||
return DevicePostureValue{Kind: DevicePostureValueKindConfigured}
|
||||
}
|
||||
|
||||
func textValue(text string) DevicePostureValue {
|
||||
text = truncateValue(text, devicePostureValueTextMax)
|
||||
if text == "" {
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
return DevicePostureValue{
|
||||
Kind: DevicePostureValueKindText,
|
||||
Text: text,
|
||||
}
|
||||
}
|
||||
|
||||
func secondsValue(seconds int) DevicePostureValue {
|
||||
return DevicePostureValue{
|
||||
Kind: DevicePostureValueKindSeconds,
|
||||
Number: new(seconds),
|
||||
}
|
||||
}
|
||||
|
||||
func minPasswordLengthValue(length int) DevicePostureValue {
|
||||
return DevicePostureValue{
|
||||
Kind: DevicePostureValueKindMinPasswordLength,
|
||||
Number: new(length),
|
||||
}
|
||||
}
|
||||
|
||||
// backendOf returns the tool the agent used to gather the evidence. Checks that
|
||||
// probe a single tool on every platform do not set it.
|
||||
func backendOf(ev map[string]any) string {
|
||||
return strings.ToLower(stringEvidence(ev, "backend"))
|
||||
}
|
||||
|
||||
// hasAnyKey discriminates platforms for checks where the agent sets no backend
|
||||
// key but the key set itself is distinctive.
|
||||
func hasAnyKey(ev map[string]any, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
if _, ok := ev[key]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func lowerStringEvidence(ev map[string]any, key string) string {
|
||||
return strings.ToLower(stringEvidence(ev, key))
|
||||
}
|
||||
|
||||
func stringEvidence(ev map[string]any, key string) string {
|
||||
v, ok := ev[key]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch typed := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case bool:
|
||||
if typed {
|
||||
return "true"
|
||||
}
|
||||
|
||||
return "false"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func boolEvidence(ev map[string]any, key string) (bool, bool) {
|
||||
v, ok := ev[key]
|
||||
if !ok || v == nil {
|
||||
return false, false
|
||||
}
|
||||
|
||||
switch typed := v.(type) {
|
||||
case bool:
|
||||
return typed, true
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(typed)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true, true
|
||||
case "0", "false", "no", "off":
|
||||
return false, true
|
||||
}
|
||||
case float64:
|
||||
return typed != 0, true
|
||||
}
|
||||
|
||||
return false, false
|
||||
}
|
||||
|
||||
func numberEvidence(ev map[string]any, key string) (int, bool) {
|
||||
v, ok := ev[key]
|
||||
if !ok || v == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
switch typed := v.(type) {
|
||||
case float64:
|
||||
return int(typed), true
|
||||
case int:
|
||||
return typed, true
|
||||
case string:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(typed))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return n, true
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func stringSliceEvidence(ev map[string]any, key string) []string {
|
||||
v, ok := ev[key]
|
||||
if !ok || v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch typed := v.(type) {
|
||||
case []string:
|
||||
out := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if s := strings.TrimSpace(item); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
case []any:
|
||||
out := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// stringMapEvidence reads a per-subject map such as the Windows firewall
|
||||
// "profiles" or the per-user screen lock "users".
|
||||
func stringMapEvidence(ev map[string]any, key string) map[string]string {
|
||||
v, ok := ev[key]
|
||||
if !ok || v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
typed, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make(map[string]string, len(typed))
|
||||
|
||||
for name, raw := range typed {
|
||||
s, ok := raw.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
out[name] = strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// allValuesMatch reports whether the map is non-empty and every value equals
|
||||
// want, case-insensitively.
|
||||
func allValuesMatch(values map[string]string, want string) (allMatch bool, any bool) {
|
||||
if len(values) == 0 {
|
||||
return false, false
|
||||
}
|
||||
|
||||
for _, v := range values {
|
||||
if !strings.EqualFold(v, want) {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
|
||||
return true, true
|
||||
}
|
||||
|
||||
func allEntriesMatch(values []string, want string) (allMatch bool, any bool) {
|
||||
if len(values) == 0 {
|
||||
return false, false
|
||||
}
|
||||
|
||||
for _, v := range values {
|
||||
if !strings.EqualFold(v, want) {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
|
||||
return true, true
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, v := range values {
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseLabeledInt finds lines like "Minimum password length: 8".
|
||||
func parseLabeledInt(raw, label string) (int, bool) {
|
||||
if raw == "" || label == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
lower := strings.ToLower(raw)
|
||||
label = strings.ToLower(label)
|
||||
|
||||
idx := strings.Index(lower, label)
|
||||
if idx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
rest := raw[idx+len(label):]
|
||||
rest = strings.TrimLeft(rest, " \t.:")
|
||||
|
||||
return parseLeadingInt(rest)
|
||||
}
|
||||
|
||||
// parseAssignedInt finds assignments like "minpasswordlen=8".
|
||||
func parseAssignedInt(raw, key string) (int, bool) {
|
||||
if raw == "" || key == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
lower := strings.ToLower(raw)
|
||||
key = strings.ToLower(key) + "="
|
||||
|
||||
idx := strings.Index(lower, key)
|
||||
if idx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return parseLeadingInt(raw[idx+len(key):])
|
||||
}
|
||||
|
||||
func parseLeadingInt(s string) (int, bool) {
|
||||
s = strings.TrimLeft(s, " \t")
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
end := 0
|
||||
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
|
||||
if end == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(s[:end])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return n, true
|
||||
}
|
||||
|
||||
// truncateValue collapses whitespace and caps the length on a rune boundary so
|
||||
// the result stays valid UTF-8 for JSON encoding.
|
||||
func truncateValue(v string, max int) string {
|
||||
v = strings.Join(strings.Fields(v), " ")
|
||||
if len(v) <= max {
|
||||
return v
|
||||
}
|
||||
|
||||
cut := max - len("…")
|
||||
for cut > 0 && !utf8.RuneStart(v[cut]) {
|
||||
cut--
|
||||
}
|
||||
|
||||
return v[:cut] + "…"
|
||||
}
|
||||
584
pkg/coredata/device_posture_value_checks.go
Normal file
584
pkg/coredata/device_posture_value_checks.go
Normal file
@@ -0,0 +1,584 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// One parser per posture check. Each dispatches on the tool the agent used, and
|
||||
// the tools are enumerated in pkg/deviceagent/checks — read those alongside
|
||||
// these branches, since they define what each backend's output means.
|
||||
|
||||
func parseOSVersionValue(ev map[string]any) DevicePostureValue {
|
||||
return textValue(
|
||||
firstNonEmptyString(
|
||||
stringEvidence(ev, "product_version"),
|
||||
stringEvidence(ev, "pretty_name"),
|
||||
stringEvidence(ev, "version_id"),
|
||||
stringEvidence(ev, "version"),
|
||||
stringEvidence(ev, "caption"),
|
||||
stringEvidence(ev, "release"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func parseDiskEncryptionValue(ev map[string]any) DevicePostureValue {
|
||||
// Linux is the only platform reporting crypttab, so the key doubles as the
|
||||
// platform discriminator.
|
||||
if present, ok := boolEvidence(ev, "crypttab_present"); ok {
|
||||
return parseLinuxDiskEncryptionValue(ev, present)
|
||||
}
|
||||
|
||||
raw := lowerStringEvidence(ev, "raw")
|
||||
switch {
|
||||
case raw == "":
|
||||
return unknownValue()
|
||||
case strings.Contains(raw, "filevault is on"):
|
||||
return onOffValue(true)
|
||||
case strings.Contains(raw, "filevault is off"):
|
||||
return onOffValue(false)
|
||||
case strings.Contains(raw, "percentage encrypted: 100"),
|
||||
strings.Contains(raw, "fully encrypted"),
|
||||
strings.Contains(raw, "protection on"):
|
||||
return onOffValue(true)
|
||||
case strings.Contains(raw, "percentage encrypted: 0"),
|
||||
strings.Contains(raw, "fully decrypted"),
|
||||
strings.Contains(raw, "protection off"):
|
||||
return onOffValue(false)
|
||||
case strings.Contains(raw, "components"):
|
||||
// FreeBSD geli prints a "Name Status Components" table with one row per
|
||||
// encrypted provider; ACTIVE is the only status meaning attached.
|
||||
return onOffValue(strings.Contains(raw, "active"))
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
func parseLinuxDiskEncryptionValue(
|
||||
ev map[string]any,
|
||||
crypttabPresent bool,
|
||||
) DevicePostureValue {
|
||||
if crypttabPresent && len(stringSliceEvidence(ev, "crypttab_lines")) > 0 {
|
||||
return onOffValue(true)
|
||||
}
|
||||
|
||||
if lsblk := stringEvidence(ev, "lsblk"); lsblk != "" {
|
||||
return onOffValue(lsblkHasCryptDevice(lsblk))
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
// lsblkHasCryptDevice reports whether `lsblk -o NAME,TYPE,... -r` listed a
|
||||
// device of type crypt, which is how a LUKS mapping appears.
|
||||
func lsblkHasCryptDevice(raw string) bool {
|
||||
for line := range strings.SplitSeq(raw, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
if fields[1] == "crypt" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func parseScreenLockValue(ev map[string]any) DevicePostureValue {
|
||||
switch backendOf(ev) {
|
||||
case "sysadminctl":
|
||||
return parseDarwinScreenLockModeValue(ev)
|
||||
case "gnome", "cinnamon", "mate", "ukui":
|
||||
return boolKeyValue(ev, "lock_enabled")
|
||||
case "kde":
|
||||
return boolKeyValue(ev, "autolock")
|
||||
case "xfce":
|
||||
return boolKeyValue(ev, "enabled")
|
||||
case "i3":
|
||||
return parseI3ScreenLockValue(ev)
|
||||
case "machine_policy":
|
||||
return boolKeyValue(ev, "screen_saver_is_secure")
|
||||
case "hkey_users":
|
||||
return parseWindowsUserScreenLockValue(ev)
|
||||
}
|
||||
|
||||
// macOS falls back to the com.apple.screensaver defaults, which set no
|
||||
// backend key.
|
||||
if ask, ok := boolEvidence(ev, "ask_for_password"); ok {
|
||||
if !ask {
|
||||
return onOffValue(false)
|
||||
}
|
||||
|
||||
if delay, ok := numberEvidence(ev, "ask_for_password_delay"); ok {
|
||||
return screenLockDelayValue(delay)
|
||||
}
|
||||
|
||||
return onOffValue(true)
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
func parseDarwinScreenLockModeValue(ev map[string]any) DevicePostureValue {
|
||||
switch stringEvidence(ev, "mode") {
|
||||
case "immediate":
|
||||
return DevicePostureValue{Kind: DevicePostureValueKindImmediate}
|
||||
case "off":
|
||||
return onOffValue(false)
|
||||
case "seconds":
|
||||
if seconds, ok := numberEvidence(ev, "seconds"); ok {
|
||||
return screenLockDelayValue(seconds)
|
||||
}
|
||||
|
||||
return onOffValue(true)
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
func parseI3ScreenLockValue(ev map[string]any) DevicePostureValue {
|
||||
if stringEvidence(ev, "error") != "" {
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
// No idle lock command in the config at all.
|
||||
if stringEvidence(ev, "mechanism") == "" {
|
||||
return onOffValue(false)
|
||||
}
|
||||
|
||||
if minutes, ok := numberEvidence(ev, "idle_minutes"); ok && minutes > 0 {
|
||||
return secondsValue(minutes * 60)
|
||||
}
|
||||
|
||||
return onOffValue(true)
|
||||
}
|
||||
|
||||
func parseWindowsUserScreenLockValue(ev map[string]any) DevicePostureValue {
|
||||
allSecure, any := allValuesMatch(stringMapEvidence(ev, "users"), "1")
|
||||
if !any {
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
return onOffValue(allSecure)
|
||||
}
|
||||
|
||||
func screenLockDelayValue(seconds int) DevicePostureValue {
|
||||
if seconds <= 0 {
|
||||
return DevicePostureValue{Kind: DevicePostureValueKindImmediate}
|
||||
}
|
||||
|
||||
return secondsValue(seconds)
|
||||
}
|
||||
|
||||
func parseFirewallValue(ev map[string]any) DevicePostureValue {
|
||||
switch backendOf(ev) {
|
||||
case "defaults":
|
||||
return parseDarwinFirewallStateValue(ev)
|
||||
case "socketfilterfw":
|
||||
return parseSocketFilterFWValue(ev)
|
||||
case "ufw":
|
||||
return parseUFWValue(ev)
|
||||
case "firewalld":
|
||||
return parseFirewalldValue(ev)
|
||||
case "nftables":
|
||||
return parseNftablesValue(ev)
|
||||
case "iptables":
|
||||
return parseIptablesValue(ev)
|
||||
case "get-netfirewallprofile":
|
||||
return parseWindowsFirewallProfilesValue(ev)
|
||||
case "netsh":
|
||||
return parseNetshFirewallValue(ev)
|
||||
}
|
||||
|
||||
// FreeBSD pfctl sets no backend key.
|
||||
raw := lowerStringEvidence(ev, "raw")
|
||||
switch {
|
||||
case strings.Contains(raw, "status: enabled"):
|
||||
return onOffValue(true)
|
||||
case strings.Contains(raw, "status: disabled"):
|
||||
return onOffValue(false)
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
// parseDarwinFirewallStateValue reads com.apple.alf globalstate, where 1 blocks
|
||||
// incoming connections and 2 blocks all but essential services.
|
||||
func parseDarwinFirewallStateValue(ev map[string]any) DevicePostureValue {
|
||||
switch stringEvidence(ev, "global_state") {
|
||||
case "1", "2":
|
||||
return onOffValue(true)
|
||||
case "0":
|
||||
return onOffValue(false)
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
func parseSocketFilterFWValue(ev map[string]any) DevicePostureValue {
|
||||
raw := lowerStringEvidence(ev, "raw")
|
||||
switch {
|
||||
case strings.Contains(raw, "disabled"), strings.Contains(raw, "state = 0"):
|
||||
return onOffValue(false)
|
||||
case strings.Contains(raw, "enabled"),
|
||||
strings.Contains(raw, "state = 1"),
|
||||
strings.Contains(raw, "state = 2"):
|
||||
return onOffValue(true)
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
// parseUFWValue reads `ufw status`. The whole "status: <state>" phrase has to
|
||||
// match: "inactive" contains "active", so a bare substring test reads a
|
||||
// disabled firewall as enabled.
|
||||
func parseUFWValue(ev map[string]any) DevicePostureValue {
|
||||
raw := lowerStringEvidence(ev, "raw")
|
||||
switch {
|
||||
case strings.Contains(raw, "status: inactive"):
|
||||
return onOffValue(false)
|
||||
case strings.Contains(raw, "status: active"):
|
||||
return onOffValue(true)
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
// parseFirewalldValue reads `firewall-cmd --state`, which prints "running" or
|
||||
// "not running" — so the negative has to be tested first.
|
||||
func parseFirewalldValue(ev map[string]any) DevicePostureValue {
|
||||
raw := lowerStringEvidence(ev, "raw")
|
||||
switch {
|
||||
case strings.Contains(raw, "not running"):
|
||||
return onOffValue(false)
|
||||
case strings.Contains(raw, "running"):
|
||||
return onOffValue(true)
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
func parseNftablesValue(ev map[string]any) DevicePostureValue {
|
||||
if stringEvidence(ev, "error") != "" {
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
excerpt := stringEvidence(ev, "rules_excerpt")
|
||||
if excerpt == "" {
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
return onOffValue(strings.Contains(excerpt, "chain "))
|
||||
}
|
||||
|
||||
// parseIptablesValue reads the INPUT chain default policy. An ACCEPT policy
|
||||
// carrying rules cannot be classified without modelling the whole chain, which
|
||||
// is what the agent declines to do as well.
|
||||
func parseIptablesValue(ev map[string]any) DevicePostureValue {
|
||||
if stringEvidence(ev, "error") != "" {
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
switch strings.ToUpper(stringEvidence(ev, "input_policy")) {
|
||||
case "DROP", "REJECT":
|
||||
return onOffValue(true)
|
||||
case "ACCEPT":
|
||||
if rules, ok := numberEvidence(ev, "input_rules"); ok && rules == 0 {
|
||||
return onOffValue(false)
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
func parseWindowsFirewallProfilesValue(ev map[string]any) DevicePostureValue {
|
||||
allEnabled, any := allValuesMatch(stringMapEvidence(ev, "profiles"), "true")
|
||||
if !any {
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
return onOffValue(allEnabled)
|
||||
}
|
||||
|
||||
func parseNetshFirewallValue(ev map[string]any) DevicePostureValue {
|
||||
allOn, any := allEntriesMatch(stringSliceEvidence(ev, "state_lines"), "on")
|
||||
if !any {
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
return onOffValue(allOn)
|
||||
}
|
||||
|
||||
func parseTimeSyncValue(ev map[string]any) DevicePostureValue {
|
||||
raw := lowerStringEvidence(ev, "raw")
|
||||
switch {
|
||||
case raw == "":
|
||||
return unknownValue()
|
||||
case strings.Contains(raw, "ntpsynchronized=yes"):
|
||||
return onOffValue(true)
|
||||
case strings.Contains(raw, "ntpsynchronized=no"):
|
||||
return onOffValue(false)
|
||||
case strings.Contains(raw, "network time: on"):
|
||||
return onOffValue(true)
|
||||
case strings.Contains(raw, "network time: off"):
|
||||
return onOffValue(false)
|
||||
case strings.Contains(raw, "is not running"):
|
||||
return onOffValue(false)
|
||||
case strings.Contains(raw, "is running"):
|
||||
return onOffValue(true)
|
||||
case strings.Contains(raw, "local cmos clock"):
|
||||
// Windows w32tm: the local clock is not a synchronisation source.
|
||||
return onOffValue(false)
|
||||
case strings.Contains(raw, "source:"):
|
||||
return onOffValue(true)
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
func parseAutoUpdateValue(ev map[string]any) DevicePostureValue {
|
||||
switch backendOf(ev) {
|
||||
case "defaults":
|
||||
return parseDarwinSoftwareUpdateValue(ev)
|
||||
case "unattended-upgrades":
|
||||
// Each APT periodic task is enabled with a quoted "1".
|
||||
return onOffValue(strings.Contains(stringEvidence(ev, "raw"), `"1"`))
|
||||
case "dnf-automatic":
|
||||
return unitStateValue(stringEvidence(ev, "state"))
|
||||
}
|
||||
|
||||
// The Windows Update policy read sets no backend key.
|
||||
if hasAnyKey(ev, "no_auto_update", "au_options", "wuauserv") {
|
||||
return parseWindowsAutoUpdateValue(ev)
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
// parseDarwinSoftwareUpdateValue collapses the five Software Update preferences
|
||||
// into one value: any preference off makes automatic updates off.
|
||||
func parseDarwinSoftwareUpdateValue(ev map[string]any) DevicePostureValue {
|
||||
if len(stringSliceEvidence(ev, "disabled_keys")) > 0 {
|
||||
return onOffValue(false)
|
||||
}
|
||||
|
||||
if len(stringSliceEvidence(ev, "indeterminate_keys")) > 0 {
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
return onOffValue(true)
|
||||
}
|
||||
|
||||
func parseWindowsAutoUpdateValue(ev map[string]any) DevicePostureValue {
|
||||
if stringEvidence(ev, "no_auto_update") == "1" {
|
||||
return onOffValue(false)
|
||||
}
|
||||
|
||||
// AUOptions: 2 notifies only, 3 downloads, 4 downloads and installs, 5
|
||||
// delegates to local administrators.
|
||||
switch stringEvidence(ev, "au_options") {
|
||||
case "3", "4", "5":
|
||||
return onOffValue(true)
|
||||
case "2":
|
||||
return onOffValue(false)
|
||||
}
|
||||
|
||||
// With no managed policy the value is whether the Windows Update service is
|
||||
// running to apply the OS default.
|
||||
switch stringEvidence(ev, "wuauserv") {
|
||||
case "running":
|
||||
return onOffValue(true)
|
||||
case "stopped":
|
||||
return onOffValue(false)
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
func parsePasswordPolicyValue(ev map[string]any) DevicePostureValue {
|
||||
// Linux reads PASS_MIN_LEN from /etc/login.defs.
|
||||
if minLen, ok := numberEvidence(ev, "pass_min_len_value"); ok {
|
||||
return minPasswordLengthValue(minLen)
|
||||
}
|
||||
|
||||
if minLen, ok := numberEvidence(ev, "pass_min_len"); ok {
|
||||
return minPasswordLengthValue(minLen)
|
||||
}
|
||||
|
||||
if parseError := lowerStringEvidence(ev, "parse_error"); parseError != "" {
|
||||
if strings.Contains(parseError, "not set") {
|
||||
return noneValue()
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
// Windows `net accounts`.
|
||||
if minLen, ok := parseLabeledInt(
|
||||
stringEvidence(ev, "raw"),
|
||||
"minimum password length",
|
||||
); ok {
|
||||
return minPasswordLengthValue(minLen)
|
||||
}
|
||||
|
||||
// FreeBSD /etc/login.conf.
|
||||
if snippet := stringEvidence(ev, "login_conf_snippet"); snippet != "" {
|
||||
if minLen, ok := parseAssignedInt(snippet, "minpasswordlen"); ok {
|
||||
return minPasswordLengthValue(minLen)
|
||||
}
|
||||
|
||||
if strings.Contains(strings.ToLower(snippet), "passwordtime=") {
|
||||
return configuredValue()
|
||||
}
|
||||
|
||||
return noneValue()
|
||||
}
|
||||
|
||||
// macOS pwpolicy returns the policy plist, which has no single figure.
|
||||
if _, ok := ev["raw_truncated"]; ok {
|
||||
raw := lowerStringEvidence(ev, "raw_truncated")
|
||||
if raw == "" || strings.Contains(raw, "no account policies") {
|
||||
return noneValue()
|
||||
}
|
||||
|
||||
return configuredValue()
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
// parseRemoteLoginValue reports whether remote login is reachable, so On is the
|
||||
// insecure observation here.
|
||||
func parseRemoteLoginValue(ev map[string]any) DevicePostureValue {
|
||||
// Windows: fDenyTSConnections=1 refuses Terminal Server connections.
|
||||
if deny, ok := boolEvidence(ev, "fdeny_ts_connections"); ok {
|
||||
return onOffValue(!deny)
|
||||
}
|
||||
|
||||
// Linux reports the ssh unit state.
|
||||
if _, ok := ev["is_active"]; ok {
|
||||
return unitActiveValue(stringEvidence(ev, "is_active"))
|
||||
}
|
||||
|
||||
raw := lowerStringEvidence(ev, "raw")
|
||||
switch {
|
||||
case strings.Contains(raw, "remote login: on"):
|
||||
return onOffValue(true)
|
||||
case strings.Contains(raw, "remote login: off"):
|
||||
return onOffValue(false)
|
||||
case strings.Contains(raw, "is not running"):
|
||||
return onOffValue(false)
|
||||
case strings.Contains(raw, "is running"):
|
||||
return onOffValue(true)
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
func parseMalwareProtectionValue(ev map[string]any) DevicePostureValue {
|
||||
// Windows Defender.
|
||||
if antivirus, ok := boolEvidence(ev, "antivirus_enabled"); ok {
|
||||
realtime, _ := boolEvidence(ev, "real_time_protection")
|
||||
service, _ := boolEvidence(ev, "am_service_enabled")
|
||||
|
||||
return onOffValue(antivirus && (realtime || service))
|
||||
}
|
||||
|
||||
// macOS XProtect. The plist path is never surfaced.
|
||||
if engine := stringEvidence(ev, "engine"); engine != "" {
|
||||
if strings.Contains(lowerStringEvidence(ev, "note"), "not found") {
|
||||
return noneValue()
|
||||
}
|
||||
|
||||
if version := stringEvidence(ev, "version"); version != "" {
|
||||
return textValue(engine + " " + version)
|
||||
}
|
||||
|
||||
return textValue(engine)
|
||||
}
|
||||
|
||||
// Linux endpoint agents. Running agents are the value; agents installed but
|
||||
// not running mean the protection is off.
|
||||
if _, ok := ev["active"]; ok {
|
||||
if active := stringSliceEvidence(ev, "active"); len(active) > 0 {
|
||||
return textValue(strings.Join(active, ", "))
|
||||
}
|
||||
|
||||
if len(stringSliceEvidence(ev, "installed")) > 0 {
|
||||
return onOffValue(false)
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
// FreeBSD clamav.
|
||||
raw := lowerStringEvidence(ev, "raw")
|
||||
switch {
|
||||
case strings.Contains(raw, "is not running"):
|
||||
return onOffValue(false)
|
||||
case strings.Contains(raw, "is running"):
|
||||
return onOffValue(true)
|
||||
}
|
||||
|
||||
if strings.Contains(lowerStringEvidence(ev, "note"), "not installed") {
|
||||
return noneValue()
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
// unitStateValue maps `systemctl is-enabled` output.
|
||||
func unitStateValue(state string) DevicePostureValue {
|
||||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||
case "":
|
||||
return unknownValue()
|
||||
case "enabled", "enabled-runtime":
|
||||
return onOffValue(true)
|
||||
default:
|
||||
return onOffValue(false)
|
||||
}
|
||||
}
|
||||
|
||||
// unitActiveValue maps `systemctl is-active` output.
|
||||
func unitActiveValue(state string) DevicePostureValue {
|
||||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||
case "active", "activating":
|
||||
return onOffValue(true)
|
||||
case "inactive", "failed", "deactivating":
|
||||
return onOffValue(false)
|
||||
}
|
||||
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
func boolKeyValue(ev map[string]any, key string) DevicePostureValue {
|
||||
v, ok := boolEvidence(ev, key)
|
||||
if !ok {
|
||||
return unknownValue()
|
||||
}
|
||||
|
||||
return onOffValue(v)
|
||||
}
|
||||
121
pkg/coredata/device_posture_value_kind.go
Normal file
121
pkg/coredata/device_posture_value_kind.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DevicePostureValueKind classifies the observed value of a posture check so
|
||||
// clients can localize it. It says nothing about whether the observation is
|
||||
// acceptable — rulesets own that verdict.
|
||||
type DevicePostureValueKind string
|
||||
|
||||
const (
|
||||
// DevicePostureValueKindOn and DevicePostureValueKindOff are boolean
|
||||
// observations: the feature is turned on, or it is turned off.
|
||||
DevicePostureValueKindOn DevicePostureValueKind = "ON"
|
||||
DevicePostureValueKindOff DevicePostureValueKind = "OFF"
|
||||
|
||||
// DevicePostureValueKindImmediate is a screen lock with no grace period.
|
||||
DevicePostureValueKindImmediate DevicePostureValueKind = "IMMEDIATE"
|
||||
|
||||
// DevicePostureValueKindSeconds carries a delay in Number.
|
||||
DevicePostureValueKindSeconds DevicePostureValueKind = "SECONDS"
|
||||
|
||||
// DevicePostureValueKindMinPasswordLength carries a character count in
|
||||
// Number.
|
||||
DevicePostureValueKindMinPasswordLength DevicePostureValueKind = "MIN_PASSWORD_LENGTH"
|
||||
|
||||
// DevicePostureValueKindConfigured means a policy exists but its content
|
||||
// could not be reduced to a single figure.
|
||||
DevicePostureValueKindConfigured DevicePostureValueKind = "CONFIGURED"
|
||||
|
||||
// DevicePostureValueKindNone means the host positively reported the
|
||||
// absence of the thing being checked.
|
||||
DevicePostureValueKindNone DevicePostureValueKind = "NONE"
|
||||
|
||||
// DevicePostureValueKindText carries a literal in Text that needs no
|
||||
// translation: an OS version, an engine name, a list of agents.
|
||||
DevicePostureValueKindText DevicePostureValueKind = "TEXT"
|
||||
|
||||
// DevicePostureValueKindUnknown means the evidence did not answer the
|
||||
// question. It is never a guess.
|
||||
DevicePostureValueKindUnknown DevicePostureValueKind = "UNKNOWN"
|
||||
)
|
||||
|
||||
var (
|
||||
_ fmt.Stringer = DevicePostureValueKind("")
|
||||
_ encoding.TextMarshaler = DevicePostureValueKind("")
|
||||
_ encoding.TextUnmarshaler = (*DevicePostureValueKind)(nil)
|
||||
)
|
||||
|
||||
func DevicePostureValueKinds() []DevicePostureValueKind {
|
||||
return []DevicePostureValueKind{
|
||||
DevicePostureValueKindOn,
|
||||
DevicePostureValueKindOff,
|
||||
DevicePostureValueKindImmediate,
|
||||
DevicePostureValueKindSeconds,
|
||||
DevicePostureValueKindMinPasswordLength,
|
||||
DevicePostureValueKindConfigured,
|
||||
DevicePostureValueKindNone,
|
||||
DevicePostureValueKindText,
|
||||
DevicePostureValueKindUnknown,
|
||||
}
|
||||
}
|
||||
|
||||
func (v DevicePostureValueKind) IsValid() bool {
|
||||
switch v {
|
||||
case
|
||||
DevicePostureValueKindOn,
|
||||
DevicePostureValueKindOff,
|
||||
DevicePostureValueKindImmediate,
|
||||
DevicePostureValueKindSeconds,
|
||||
DevicePostureValueKindMinPasswordLength,
|
||||
DevicePostureValueKindConfigured,
|
||||
DevicePostureValueKindNone,
|
||||
DevicePostureValueKindText,
|
||||
DevicePostureValueKindUnknown:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (v DevicePostureValueKind) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
func (v DevicePostureValueKind) MarshalText() ([]byte, error) {
|
||||
return []byte(v.String()), nil
|
||||
}
|
||||
|
||||
func (v *DevicePostureValueKind) UnmarshalText(text []byte) error {
|
||||
val := DevicePostureValueKind(text)
|
||||
if !val.IsValid() {
|
||||
return fmt.Errorf("invalid DevicePostureValueKind value: %q", string(text))
|
||||
}
|
||||
|
||||
*v = val
|
||||
|
||||
return nil
|
||||
}
|
||||
1070
pkg/coredata/device_posture_value_test.go
Normal file
1070
pkg/coredata/device_posture_value_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -139,6 +139,7 @@ const (
|
||||
DeviceEntityType uint16 = 107
|
||||
DevicePostureEntityType uint16 = 108
|
||||
DeviceEnrollmentTokenEntityType uint16 = 109
|
||||
DevicePostureReportEntityType uint16 = 110
|
||||
)
|
||||
|
||||
func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
@@ -345,6 +346,8 @@ func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
return &DevicePosture{ID: id}, true
|
||||
case DeviceEnrollmentTokenEntityType:
|
||||
return &DeviceEnrollmentToken{ID: id}, true
|
||||
case DevicePostureReportEntityType:
|
||||
return &DevicePostureReport{ID: id}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
|
||||
48
pkg/coredata/migrations/20260728T174416Z.sql
Normal file
48
pkg/coredata/migrations/20260728T174416Z.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
--
|
||||
-- Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
-- of this software and associated documentation files (the "Software"), to deal
|
||||
-- in the Software without restriction, including without limitation the rights
|
||||
-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
-- copies of the Software, and to permit persons to whom the Software is
|
||||
-- furnished to do so, subject to the following conditions:
|
||||
--
|
||||
-- The above copyright notice and this permission notice shall be included in
|
||||
-- all copies or substantial portions of the Software.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
-- SOFTWARE.
|
||||
|
||||
ALTER TABLE device_postures
|
||||
ADD COLUMN correlation_id TEXT;
|
||||
|
||||
WITH report_ids AS (
|
||||
SELECT
|
||||
device_id,
|
||||
created_at,
|
||||
generate_gid(parse_tenant_id(MIN(tenant_id)), 110) AS correlation_id
|
||||
FROM
|
||||
device_postures
|
||||
GROUP BY
|
||||
device_id,
|
||||
created_at
|
||||
)
|
||||
UPDATE device_postures AS dp
|
||||
SET
|
||||
correlation_id = report_ids.correlation_id
|
||||
FROM
|
||||
report_ids
|
||||
WHERE
|
||||
dp.device_id = report_ids.device_id
|
||||
AND dp.created_at = report_ids.created_at;
|
||||
|
||||
ALTER TABLE device_postures
|
||||
ALTER COLUMN correlation_id SET NOT NULL;
|
||||
|
||||
CREATE INDEX device_postures_device_id_correlation_id_created_at_idx
|
||||
ON device_postures (device_id, correlation_id, created_at DESC);
|
||||
@@ -28,8 +28,10 @@ import (
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/deviceagent/checks"
|
||||
"go.probo.inc/probo/pkg/deviceagent/update"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -450,15 +452,27 @@ func (a *Agent) doPostures(ctx context.Context) {
|
||||
log.Duration("per_check_timeout", perCheckTimeout),
|
||||
)
|
||||
|
||||
deviceID, err := gid.ParseGID(a.cfg.DeviceID)
|
||||
if err != nil {
|
||||
a.Logger.ErrorCtx(ctx, "cannot parse device id for posture correlation", log.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
correlationID := gid.New(
|
||||
deviceID.TenantID(),
|
||||
coredata.DevicePostureReportEntityType,
|
||||
).String()
|
||||
|
||||
payload := make([]PostureResultPayload, 0, len(results))
|
||||
for _, r := range results {
|
||||
payload = append(
|
||||
payload,
|
||||
PostureResultPayload{
|
||||
CheckKey: r.CheckKey,
|
||||
Status: string(r.Status),
|
||||
Evidence: checks.EvidenceJSON(r.Evidence),
|
||||
ObservedAt: r.ObservedAt,
|
||||
CheckKey: r.CheckKey,
|
||||
Status: string(r.Status),
|
||||
Evidence: checks.EvidenceJSON(r.Evidence),
|
||||
ObservedAt: r.ObservedAt,
|
||||
CorrelationID: correlationID,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,10 +75,11 @@ type (
|
||||
}
|
||||
|
||||
PostureResultPayload struct {
|
||||
CheckKey string `json:"check_key"`
|
||||
Status string `json:"status"`
|
||||
Evidence json.RawMessage `json:"evidence,omitempty"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
CheckKey string `json:"check_key"`
|
||||
Status string `json:"status"`
|
||||
Evidence json.RawMessage `json:"evidence,omitempty"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
CorrelationID string `json:"correlation_id"`
|
||||
}
|
||||
|
||||
PosturesRequest struct {
|
||||
|
||||
@@ -57,6 +57,18 @@ var (
|
||||
// ErrEnrollmentTokenInvalid is returned when an enrollment token
|
||||
// cannot be exchanged for the device.
|
||||
ErrEnrollmentTokenInvalid = errors.New("enrollment token invalid")
|
||||
|
||||
// ErrCorrelationIDRequired is returned when a posture result is
|
||||
// missing a correlation ID.
|
||||
ErrCorrelationIDRequired = errors.New("correlation_id is required")
|
||||
|
||||
// ErrInvalidCorrelationIDEntityType is returned when a posture
|
||||
// correlation ID is not a DevicePostureReport entity.
|
||||
ErrInvalidCorrelationIDEntityType = errors.New("correlation_id entity type is invalid")
|
||||
|
||||
// ErrInvalidCorrelationIDTenant is returned when a posture
|
||||
// correlation ID belongs to a different tenant than the device.
|
||||
ErrInvalidCorrelationIDTenant = errors.New("correlation_id tenant is invalid")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -109,10 +121,11 @@ type (
|
||||
}
|
||||
|
||||
RecordPostureResult struct {
|
||||
CheckKey string
|
||||
Status coredata.DevicePostureStatus
|
||||
Evidence json.RawMessage
|
||||
ObservedAt time.Time
|
||||
CheckKey string
|
||||
Status coredata.DevicePostureStatus
|
||||
Evidence json.RawMessage
|
||||
ObservedAt time.Time
|
||||
CorrelationID gid.GID
|
||||
}
|
||||
|
||||
ServiceConfig struct {
|
||||
@@ -670,6 +683,113 @@ func (s *Service) GetPostureHistory(
|
||||
return postures, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListPostureReports(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
deviceID gid.GID,
|
||||
cursor *page.Cursor[coredata.DevicePostureReportOrderField],
|
||||
) (*page.Page[*coredata.DevicePostureReport, coredata.DevicePostureReportOrderField], error) {
|
||||
var result *page.Page[*coredata.DevicePostureReport, coredata.DevicePostureReportOrderField]
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) error {
|
||||
var reports coredata.DevicePostureReports
|
||||
if err := reports.LoadByDeviceID(ctx, conn, scope, deviceID, cursor); err != nil {
|
||||
return fmt.Errorf("cannot load device posture reports: %w", err)
|
||||
}
|
||||
|
||||
p := page.NewPage(reports, cursor)
|
||||
if err := attachPosturesToReports(ctx, conn, scope, deviceID, p.Data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result = p
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) CountPostureReports(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
deviceID gid.GID,
|
||||
) (int, error) {
|
||||
var count int
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) error {
|
||||
var reports coredata.DevicePostureReports
|
||||
|
||||
n, err := reports.CountByDeviceID(ctx, conn, scope, deviceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count device posture reports: %w", err)
|
||||
}
|
||||
|
||||
count = n
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func attachPosturesToReports(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope coredata.Scoper,
|
||||
deviceID gid.GID,
|
||||
reports []*coredata.DevicePostureReport,
|
||||
) error {
|
||||
if len(reports) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
correlationIDs := make([]gid.GID, len(reports))
|
||||
for i, report := range reports {
|
||||
correlationIDs[i] = report.ID
|
||||
report.Postures = nil
|
||||
}
|
||||
|
||||
var postures coredata.DevicePostures
|
||||
if err := postures.LoadByDeviceIDAndCorrelationIDs(
|
||||
ctx,
|
||||
conn,
|
||||
scope,
|
||||
deviceID,
|
||||
correlationIDs,
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot load postures for reports: %w", err)
|
||||
}
|
||||
|
||||
reportsByID := make(map[gid.GID]*coredata.DevicePostureReport, len(reports))
|
||||
for _, report := range reports {
|
||||
reportsByID[report.ID] = report
|
||||
}
|
||||
|
||||
for _, posture := range postures {
|
||||
report, ok := reportsByID[posture.CorrelationID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
report.Postures = append(report.Postures, posture)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthenticateDevice resolves a device API key to its device row.
|
||||
// Returns coredata.ErrResourceNotFound when no non-revoked device
|
||||
// matches the key. Revoked devices are treated as not found.
|
||||
@@ -811,10 +931,23 @@ func (s *Service) RecordPostures(
|
||||
}
|
||||
|
||||
for _, r := range results {
|
||||
if r.CorrelationID == gid.Nil {
|
||||
return ErrCorrelationIDRequired
|
||||
}
|
||||
|
||||
if r.CorrelationID.EntityType() != coredata.DevicePostureReportEntityType {
|
||||
return ErrInvalidCorrelationIDEntityType
|
||||
}
|
||||
|
||||
if r.CorrelationID.TenantID() != device.ID.TenantID() {
|
||||
return ErrInvalidCorrelationIDTenant
|
||||
}
|
||||
|
||||
posture := coredata.DevicePosture{
|
||||
ID: gid.New(device.OrganizationID.TenantID(), coredata.DevicePostureEntityType),
|
||||
OrganizationID: device.OrganizationID,
|
||||
DeviceID: device.ID,
|
||||
CorrelationID: r.CorrelationID,
|
||||
CheckKey: r.CheckKey,
|
||||
Status: r.Status,
|
||||
Evidence: r.Evidence,
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/bearertoken"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/itam"
|
||||
"go.probo.inc/probo/pkg/server/api/agent/v1/types"
|
||||
"go.probo.inc/probo/pkg/server/jsonx"
|
||||
@@ -177,15 +178,33 @@ func (h *Handler) handlePostures(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
fallbackCorrelationID := gid.New(
|
||||
dev.ID.TenantID(),
|
||||
coredata.DevicePostureReportEntityType,
|
||||
)
|
||||
|
||||
results := make([]itam.RecordPostureResult, 0, len(req.Results))
|
||||
for _, pr := range req.Results {
|
||||
correlationID := fallbackCorrelationID
|
||||
|
||||
if pr.CorrelationID != "" {
|
||||
parsed, err := gid.ParseGID(pr.CorrelationID)
|
||||
if err != nil {
|
||||
jsonx.RenderBadRequest(w, errors.New("correlation_id is invalid"))
|
||||
return
|
||||
}
|
||||
|
||||
correlationID = parsed
|
||||
}
|
||||
|
||||
results = append(
|
||||
results,
|
||||
itam.RecordPostureResult{
|
||||
CheckKey: pr.CheckKey,
|
||||
Status: pr.Status,
|
||||
Evidence: pr.Evidence,
|
||||
ObservedAt: pr.ObservedAt,
|
||||
CheckKey: pr.CheckKey,
|
||||
Status: pr.Status,
|
||||
Evidence: pr.Evidence,
|
||||
ObservedAt: pr.ObservedAt,
|
||||
CorrelationID: correlationID,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -198,6 +217,13 @@ func (h *Handler) handlePostures(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, itam.ErrCorrelationIDRequired) ||
|
||||
errors.Is(err, itam.ErrInvalidCorrelationIDEntityType) ||
|
||||
errors.Is(err, itam.ErrInvalidCorrelationIDTenant) {
|
||||
jsonx.RenderBadRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.ErrorCtx(r.Context(), "cannot record postures", log.Error(err))
|
||||
jsonx.RenderInternalServerError(w)
|
||||
|
||||
|
||||
@@ -52,10 +52,11 @@ type (
|
||||
}
|
||||
|
||||
PostureResultPayload struct {
|
||||
CheckKey string `json:"check_key"`
|
||||
Status coredata.DevicePostureStatus `json:"status"`
|
||||
Evidence json.RawMessage `json:"evidence,omitempty"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
CheckKey string `json:"check_key"`
|
||||
Status coredata.DevicePostureStatus `json:"status"`
|
||||
Evidence json.RawMessage `json:"evidence,omitempty"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
CorrelationID string `json:"correlation_id"`
|
||||
}
|
||||
|
||||
PostureRequest struct {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/iam"
|
||||
"go.probo.inc/probo/pkg/itam"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
"go.probo.inc/probo/pkg/server/api/authn"
|
||||
"go.probo.inc/probo/pkg/server/api/authz"
|
||||
"go.probo.inc/probo/pkg/server/api/console/v1/dataloader"
|
||||
@@ -64,6 +65,36 @@ func (r *deviceResolver) LatestPostures(ctx context.Context, obj *types.Device)
|
||||
return types.NewDevicePostures(postures), nil
|
||||
}
|
||||
|
||||
// PostureReports is the resolver for the postureReports field.
|
||||
func (r *deviceResolver) PostureReports(ctx context.Context, obj *types.Device, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DevicePostureReportOrderBy) (*types.DevicePostureReportConnection, error) {
|
||||
scope, err := r.authorize(ctx, obj.ID, itam.ActionDevicePostureList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pageOrderBy := page.OrderBy[coredata.DevicePostureReportOrderField]{
|
||||
Field: coredata.DevicePostureReportOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
}
|
||||
if orderBy != nil {
|
||||
pageOrderBy = page.OrderBy[coredata.DevicePostureReportOrderField]{
|
||||
Field: orderBy.Field,
|
||||
Direction: orderBy.Direction,
|
||||
}
|
||||
}
|
||||
|
||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||
|
||||
p, err := r.itam.ListPostureReports(ctx, scope, obj.ID, cursor)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot list device posture reports", log.Error(err))
|
||||
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return types.NewDevicePostureReportConnection(p, r, obj.ID), nil
|
||||
}
|
||||
|
||||
// TotalCount is the resolver for the DeviceConnection.totalCount field.
|
||||
func (r *deviceConnectionResolver) TotalCount(ctx context.Context, obj *types.DeviceConnection) (int, error) {
|
||||
scope, err := r.authorize(ctx, obj.ParentID, itam.ActionDeviceList)
|
||||
@@ -85,6 +116,23 @@ func (r *deviceConnectionResolver) TotalCount(ctx context.Context, obj *types.De
|
||||
return 0, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
// TotalCount is the resolver for the totalCount field.
|
||||
func (r *devicePostureReportConnectionResolver) TotalCount(ctx context.Context, obj *types.DevicePostureReportConnection) (int, error) {
|
||||
scope, err := r.authorize(ctx, obj.ParentID, itam.ActionDevicePostureList)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
count, err := r.itam.CountPostureReports(ctx, scope, obj.ParentID)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot count device posture reports", log.Error(err))
|
||||
|
||||
return 0, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// EnrollDevice is the resolver for the enrollDevice field.
|
||||
// SkipAssumptionCheck: self-enrollment from /enroll runs before the viewer
|
||||
// assumes the target organization.
|
||||
@@ -220,7 +268,13 @@ func (r *Resolver) DeviceConnection() schema.DeviceConnectionResolver {
|
||||
return &deviceConnectionResolver{r}
|
||||
}
|
||||
|
||||
// DevicePostureReportConnection returns schema.DevicePostureReportConnectionResolver implementation.
|
||||
func (r *Resolver) DevicePostureReportConnection() schema.DevicePostureReportConnectionResolver {
|
||||
return &devicePostureReportConnectionResolver{r}
|
||||
}
|
||||
|
||||
type (
|
||||
deviceResolver struct{ *Resolver }
|
||||
deviceConnectionResolver struct{ *Resolver }
|
||||
deviceResolver struct{ *Resolver }
|
||||
deviceConnectionResolver struct{ *Resolver }
|
||||
devicePostureReportConnectionResolver struct{ *Resolver }
|
||||
)
|
||||
|
||||
@@ -95,6 +95,55 @@ type Device implements Node {
|
||||
|
||||
owner: Profile @goField(forceResolver: true)
|
||||
latestPostures: [DevicePosture!]! @goField(forceResolver: true)
|
||||
postureReports(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: DevicePostureReportOrder
|
||||
): DevicePostureReportConnection! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
enum DevicePostureValueKind
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKind"
|
||||
) {
|
||||
ON @goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindOn")
|
||||
OFF
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindOff")
|
||||
IMMEDIATE
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindImmediate"
|
||||
)
|
||||
SECONDS
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindSeconds"
|
||||
)
|
||||
MIN_PASSWORD_LENGTH
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindMinPasswordLength"
|
||||
)
|
||||
CONFIGURED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindConfigured"
|
||||
)
|
||||
NONE
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindNone"
|
||||
)
|
||||
TEXT
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindText")
|
||||
UNKNOWN
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindUnknown"
|
||||
)
|
||||
}
|
||||
|
||||
type DevicePostureValue
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.DevicePostureValue") {
|
||||
kind: DevicePostureValueKind!
|
||||
text: String!
|
||||
number: Int
|
||||
}
|
||||
|
||||
type DevicePosture implements Node {
|
||||
@@ -102,9 +151,48 @@ type DevicePosture implements Node {
|
||||
deviceId: ID!
|
||||
checkKey: String!
|
||||
status: DevicePostureStatus!
|
||||
value: DevicePostureValue!
|
||||
observedAt: Datetime!
|
||||
}
|
||||
|
||||
enum DevicePostureReportOrderField
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.DevicePostureReportOrderField"
|
||||
) {
|
||||
CREATED_AT
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DevicePostureReportOrderFieldCreatedAt"
|
||||
)
|
||||
}
|
||||
|
||||
input DevicePostureReportOrder
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DevicePostureReportOrderBy"
|
||||
) {
|
||||
direction: OrderDirection!
|
||||
field: DevicePostureReportOrderField!
|
||||
}
|
||||
|
||||
type DevicePostureReport {
|
||||
id: ID!
|
||||
createdAt: Datetime!
|
||||
postures: [DevicePosture!]!
|
||||
}
|
||||
|
||||
type DevicePostureReportConnection
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DevicePostureReportConnection"
|
||||
) {
|
||||
edges: [DevicePostureReportEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
type DevicePostureReportEdge {
|
||||
cursor: CursorKey!
|
||||
node: DevicePostureReport!
|
||||
}
|
||||
|
||||
type DeviceConnection
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DeviceConnection"
|
||||
|
||||
@@ -27,7 +27,8 @@ import (
|
||||
)
|
||||
|
||||
type (
|
||||
DeviceOrderBy OrderBy[coredata.DeviceOrderField]
|
||||
DeviceOrderBy OrderBy[coredata.DeviceOrderField]
|
||||
DevicePostureReportOrderBy OrderBy[coredata.DevicePostureReportOrderField]
|
||||
|
||||
DeviceConnection struct {
|
||||
TotalCount int
|
||||
@@ -47,6 +48,15 @@ type (
|
||||
Cursor page.CursorKey
|
||||
Node *Device
|
||||
}
|
||||
|
||||
DevicePostureReportConnection struct {
|
||||
TotalCount int
|
||||
Edges []*DevicePostureReportEdge
|
||||
PageInfo PageInfo
|
||||
|
||||
Resolver any
|
||||
ParentID gid.GID
|
||||
}
|
||||
)
|
||||
|
||||
func NewDeviceConnection(
|
||||
@@ -122,11 +132,14 @@ func NewDevice(d *coredata.Device) *Device {
|
||||
}
|
||||
|
||||
func NewDevicePosture(p *coredata.DevicePosture) *DevicePosture {
|
||||
value := coredata.ParseDevicePostureValue(p.CheckKey, p.Evidence)
|
||||
|
||||
return &DevicePosture{
|
||||
ID: p.ID,
|
||||
DeviceID: p.DeviceID,
|
||||
CheckKey: p.CheckKey,
|
||||
Status: p.Status,
|
||||
Value: &value,
|
||||
ObservedAt: p.ObservedAt,
|
||||
}
|
||||
}
|
||||
@@ -139,3 +152,41 @@ func NewDevicePostures(ps coredata.DevicePostures) []*DevicePosture {
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func NewDevicePostureReport(
|
||||
s *coredata.DevicePostureReport,
|
||||
) *DevicePostureReport {
|
||||
return &DevicePostureReport{
|
||||
ID: s.ID,
|
||||
CreatedAt: s.CreatedAt,
|
||||
Postures: NewDevicePostures(s.Postures),
|
||||
}
|
||||
}
|
||||
|
||||
func NewDevicePostureReportEdge(
|
||||
s *coredata.DevicePostureReport,
|
||||
orderBy coredata.DevicePostureReportOrderField,
|
||||
) *DevicePostureReportEdge {
|
||||
return &DevicePostureReportEdge{
|
||||
Cursor: s.CursorKey(orderBy),
|
||||
Node: NewDevicePostureReport(s),
|
||||
}
|
||||
}
|
||||
|
||||
func NewDevicePostureReportConnection(
|
||||
p *page.Page[*coredata.DevicePostureReport, coredata.DevicePostureReportOrderField],
|
||||
parentType any,
|
||||
parentID gid.GID,
|
||||
) *DevicePostureReportConnection {
|
||||
edges := make([]*DevicePostureReportEdge, len(p.Data))
|
||||
for i := range edges {
|
||||
edges[i] = NewDevicePostureReportEdge(p.Data[i], p.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &DevicePostureReportConnection{
|
||||
Edges: edges,
|
||||
PageInfo: *NewPageInfo(p),
|
||||
Resolver: parentType,
|
||||
ParentID: parentID,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user