Skip to content

Commit eb7a099

Browse files
committed
feat: add registry resource type for Windows
Add a new registry resource type that validates Windows registry keys natively using the golang.org/x/sys/windows/registry API. This replaces the need to shell out to PowerShell for registry checks, providing significant performance improvements (0.02s vs 44s for 250 checks). Gossfile syntax: registry: HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProductName: exists: true value: "Windows Server 2025 Datacenter" type: REG_SZ For value names containing backslashes (e.g. HardenedPaths UNC entries), use "::" as an explicit separator: registry: HKLM\...\HardenedPaths::\\*\NETLOGON: exists: true Supported hives: HKLM, HKCU, HKCR, HKU, HKCC. Supported types: REG_SZ, REG_EXPAND_SZ, REG_DWORD, REG_QWORD, REG_BINARY, REG_MULTI_SZ. On non-Windows platforms, the resource returns an error indicating it is only supported on Windows, following the NullPackage pattern.
1 parent f5b3d25 commit eb7a099

15 files changed

Lines changed: 711 additions & 2 deletions

File tree

add.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ func AddResource(fileName string, gossConfig GossConfig, resourceName, key strin
8181
res, err = gossConfig.Interfaces.AppendSysResource(key, sys, config)
8282
case resource.HTTPResourceName:
8383
res, err = gossConfig.HTTPs.AppendSysResource(key, sys, config)
84+
case resource.RegistryResourceName:
85+
res, err = gossConfig.Registries.AppendSysResource(key, sys, config)
8486
default:
8587
err = fmt.Errorf("undefined resource name: %s", resourceName)
8688
}

cmd/goss/goss.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,14 @@ func main() {
401401
return goss.AddResources(c.GlobalString("gossfile"), resource.InterfaceResourceName, c.Args(), newRuntimeConfigFromCLI(c))
402402
},
403403
},
404+
{
405+
Name: resource.RegistryResourceKey,
406+
Usage: "add new registry key",
407+
Action: func(c *cli.Context) error {
408+
fatalAlphaIfNeeded(c)
409+
return goss.AddResources(c.GlobalString("gossfile"), resource.RegistryResourceName, c.Args(), newRuntimeConfigFromCLI(c))
410+
},
411+
},
404412
},
405413
},
406414
}

docs/platforms.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ This matrix attempts to track parity across platforms.
9595
| | mtu | {{ fully_supported }} | {{ not_implemented }} | {{ not_implemented }} |
9696
| **kernel-param** | | {{ fully_supported }} | {{ n_a }} | {{ n_a }} |
9797
| | value | {{ fully_supported }} | {{ n_a }} | {{ n_a }} |
98+
| **registry** | | {{ n_a }} | {{ n_a }} | {{community_supported}} |
99+
| | exists | {{ n_a }} | {{ n_a }} | {{community_supported}} |
100+
| | value | {{ n_a }} | {{ n_a }} | {{community_supported}} |
101+
| | type | {{ n_a }} | {{ n_a }} | {{community_supported}} |
98102
| **mount** | | {{ fully_supported }} | {{ not_implemented }} | {{ not_implemented }} |
99103
| | exists | {{ fully_supported }} | {{ not_implemented }} | {{ not_implemented }} |
100104
| | opts | {{ fully_supported }} | {{ not_implemented }} | {{ n_a }} |

docs/schema.yaml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,36 @@ definitions:
297297
value:
298298
type: string
299299
default: Linux
300+
registryTest:
301+
description: |
302+
Validates the state of a Windows registry key value.
303+
The key format is HIVE\SubKey\Path\ValueName.
304+
When the value name contains backslashes, use "::" as separator:
305+
HIVE\SubKey\Path::ValueName (e.g. HKLM\...\HardenedPaths::\\*\NETLOGON).
306+
Supported hives: HKLM, HKCU, HKCR, HKU, HKCC.
307+
required:
308+
- exists
309+
properties:
310+
title: { "$ref":"#/definitions/title" }
311+
meta: { "$ref":"#/definitions/meta" }
312+
exists:
313+
type: boolean
314+
default: true
315+
value:
316+
anyOf:
317+
- type: string
318+
- type: integer
319+
default: "Windows"
320+
type:
321+
type: string
322+
default: REG_SZ
323+
enum:
324+
- REG_SZ
325+
- REG_EXPAND_SZ
326+
- REG_DWORD
327+
- REG_QWORD
328+
- REG_BINARY
329+
- REG_MULTI_SZ
300330
matchingTest:
301331
properties:
302332
title: { "$ref":"#/definitions/title" }
@@ -819,3 +849,9 @@ properties:
819849
NOTE: This check is inspecting the contents of local passwd file /etc/passwd, this does not validate remote users (e.g. LDAP).
820850
additionalProperties:
821851
$ref: "#/definitions/userTest"
852+
853+
registry:
854+
type: object
855+
description: "Validates the state of a Windows registry key value."
856+
additionalProperties:
857+
$ref: "#/definitions/registryTest"

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ require (
2323
github.com/stretchr/testify v1.9.0
2424
github.com/tidwall/gjson v1.17.1
2525
github.com/urfave/cli v1.22.14
26+
golang.org/x/sys v0.23.0
2627
gopkg.in/yaml.v3 v3.0.1
2728
gotest.tools/v3 v3.5.1
2829
)
@@ -54,7 +55,6 @@ require (
5455
golang.org/x/mod v0.19.0 // indirect
5556
golang.org/x/net v0.27.0 // indirect
5657
golang.org/x/sync v0.8.0 // indirect
57-
golang.org/x/sys v0.23.0 // indirect
5858
golang.org/x/text v0.17.0 // indirect
5959
golang.org/x/tools v0.23.0 // indirect
6060
google.golang.org/protobuf v1.34.2 // indirect

goss_config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type GossConfig struct {
2424
Interfaces resource.InterfaceMap `json:"interface,omitempty" yaml:"interface,omitempty"`
2525
HTTPs resource.HTTPMap `json:"http,omitempty" yaml:"http,omitempty"`
2626
Matchings resource.MatchingMap `json:"matching,omitempty" yaml:"matching,omitempty"`
27+
Registries resource.RegistryMap `json:"registry,omitempty" yaml:"registry,omitempty"`
2728
}
2829

2930
func NewGossConfig() *GossConfig {
@@ -44,6 +45,7 @@ func NewGossConfig() *GossConfig {
4445
Interfaces: make(resource.InterfaceMap),
4546
HTTPs: make(resource.HTTPMap),
4647
Matchings: make(resource.MatchingMap),
48+
Registries: make(resource.RegistryMap),
4749
}
4850
}
4951

@@ -109,6 +111,9 @@ func (c *GossConfig) Merge(g2 GossConfig) {
109111
for k, v := range g2.Matchings {
110112
mergeType(c.Matchings, "matching", k, v)
111113
}
114+
for k, v := range g2.Registries {
115+
mergeType(c.Registries, "registry", k, v)
116+
}
112117
}
113118

114119
func mergeType[V any](m map[string]V, t, k string, v V) {
@@ -136,6 +141,7 @@ func (c *GossConfig) Resources() []resource.Resource {
136141
c.Mounts,
137142
c.Interfaces,
138143
c.Matchings,
144+
c.Registries,
139145
)
140146

141147
for _, m := range gm {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
registry:
3+
HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProductName:
4+
exists: true
5+
value:
6+
match-regexp: "Windows.*"
7+
type: REG_SZ
8+
9+
HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\CurrentBuild:
10+
exists: true
11+
type: REG_SZ
12+
13+
HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NonExistentValue12345:
14+
exists: false
15+
16+
# Test explicit "::" separator for value names containing backslashes.
17+
# HardenedPaths entries use UNC paths as value names (e.g. \\*\NETLOGON).
18+
HKLM\SOFTWARE\Policies\Microsoft\Windows\NetworkProvider\HardenedPaths::\\*\NETLOGON:
19+
exists: true
20+
skip: true # only present on domain-joined machines with GPO applied

resource/registry.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package resource
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/goss-org/goss/system"
8+
"github.com/goss-org/goss/util"
9+
)
10+
11+
type Registry struct {
12+
Title string `json:"title,omitempty" yaml:"title,omitempty"`
13+
Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"`
14+
id string `json:"-" yaml:"-"`
15+
Name string `json:"name,omitempty" yaml:"name,omitempty"`
16+
Exists matcher `json:"exists" yaml:"exists"`
17+
Value matcher `json:"value,omitempty" yaml:"value,omitempty"`
18+
Type matcher `json:"type,omitempty" yaml:"type,omitempty"`
19+
Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"`
20+
}
21+
22+
const (
23+
RegistryResourceKey = "registry"
24+
RegistryResourceName = "Registry"
25+
)
26+
27+
func init() {
28+
registerResource(RegistryResourceKey, &Registry{})
29+
}
30+
31+
func (r *Registry) ID() string {
32+
if r.Name != "" && r.Name != r.id {
33+
return fmt.Sprintf("%s: %s", r.id, r.Name)
34+
}
35+
return r.id
36+
}
37+
38+
func (r *Registry) SetID(id string) { r.id = id }
39+
func (r *Registry) SetSkip() { r.Skip = true }
40+
func (r *Registry) TypeKey() string { return RegistryResourceKey }
41+
func (r *Registry) TypeName() string { return RegistryResourceName }
42+
func (r *Registry) GetTitle() string { return r.Title }
43+
func (r *Registry) GetMeta() meta { return r.Meta }
44+
func (r *Registry) GetName() string {
45+
if r.Name != "" {
46+
return r.Name
47+
}
48+
return r.id
49+
}
50+
51+
func (r *Registry) Validate(sys *system.System) []TestResult {
52+
ctx := context.WithValue(context.Background(), idKey{}, r.ID())
53+
skip := r.Skip
54+
sysRegistry := sys.NewRegistry(ctx, r.GetName(), sys, util.Config{})
55+
56+
var results []TestResult
57+
results = append(results, ValidateValue(r, "exists", r.Exists, sysRegistry.Exists, skip))
58+
if shouldSkip(results) {
59+
skip = true
60+
}
61+
if r.Value != nil {
62+
results = append(results, ValidateValue(r, "value", r.Value, sysRegistry.Value, skip))
63+
}
64+
if r.Type != nil {
65+
results = append(results, ValidateValue(r, "type", r.Type, sysRegistry.Type, skip))
66+
}
67+
return results
68+
}
69+
70+
func NewRegistry(sysRegistry system.Registry, config util.Config) (*Registry, error) {
71+
key := sysRegistry.Key()
72+
exists, _ := sysRegistry.Exists()
73+
if !exists {
74+
return &Registry{
75+
id: key,
76+
Exists: exists,
77+
}, nil
78+
}
79+
value, err := sysRegistry.Value()
80+
if err != nil {
81+
return nil, err
82+
}
83+
regType, err := sysRegistry.Type()
84+
if err != nil {
85+
return nil, err
86+
}
87+
return &Registry{
88+
id: key,
89+
Exists: exists,
90+
Value: value,
91+
Type: regType,
92+
}, nil
93+
}

resource/resource_list.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1529,3 +1529,101 @@ func (ret *HTTPMap) UnmarshalYAML(unmarshal func(v interface{}) error) error {
15291529
*ret = tmp
15301530
return nil
15311531
}
1532+
1533+
type RegistryMap map[string]*Registry
1534+
1535+
func (r RegistryMap) AppendSysResource(sr string, sys *system.System, config util.Config) (*Registry, error) {
1536+
ctx := context.WithValue(context.Background(), idKey{}, sr)
1537+
sysres := sys.NewRegistry(ctx, sr, sys, config)
1538+
res, err := NewRegistry(sysres, config)
1539+
if err != nil {
1540+
return nil, err
1541+
}
1542+
if old_res, ok := r[res.ID()]; ok {
1543+
res.Title = old_res.Title
1544+
res.Meta = old_res.Meta
1545+
}
1546+
r[res.ID()] = res
1547+
return res, nil
1548+
}
1549+
1550+
func (r RegistryMap) AppendSysResourceIfExists(sr string, sys *system.System) (*Registry, system.Registry, bool, error) {
1551+
ctx := context.WithValue(context.Background(), idKey{}, sr)
1552+
sysres := sys.NewRegistry(ctx, sr, sys, util.Config{})
1553+
res, err := NewRegistry(sysres, util.Config{})
1554+
if err != nil {
1555+
return nil, nil, false, err
1556+
}
1557+
if e, _ := sysres.Exists(); !e {
1558+
return res, sysres, false, nil
1559+
}
1560+
if old_res, ok := r[res.ID()]; ok {
1561+
res.Title = old_res.Title
1562+
res.Meta = old_res.Meta
1563+
}
1564+
r[res.ID()] = res
1565+
return res, sysres, true, nil
1566+
}
1567+
1568+
func (ret *RegistryMap) UnmarshalJSON(data []byte) error {
1569+
unmarshal := func(i interface{}) error {
1570+
if err := json.Unmarshal(data, i); err != nil {
1571+
return err
1572+
}
1573+
return nil
1574+
}
1575+
1576+
zero := Registry{}
1577+
whitelist, err := util.WhitelistAttrs(zero, util.JSON)
1578+
if err != nil {
1579+
return err
1580+
}
1581+
if err := util.ValidateSections(unmarshal, zero, whitelist); err != nil {
1582+
return err
1583+
}
1584+
1585+
var tmp map[string]*Registry
1586+
if err := unmarshal(&tmp); err != nil {
1587+
return err
1588+
}
1589+
1590+
typ := reflect.TypeOf(zero)
1591+
typs := strings.Split(typ.String(), ".")[1]
1592+
for id, res := range tmp {
1593+
if res == nil {
1594+
return fmt.Errorf("Could not parse resource %s:%s", typs, id)
1595+
}
1596+
res.SetID(id)
1597+
}
1598+
1599+
*ret = tmp
1600+
return nil
1601+
}
1602+
1603+
func (ret *RegistryMap) UnmarshalYAML(unmarshal func(v interface{}) error) error {
1604+
zero := Registry{}
1605+
whitelist, err := util.WhitelistAttrs(zero, util.YAML)
1606+
if err != nil {
1607+
return err
1608+
}
1609+
if err := util.ValidateSections(unmarshal, zero, whitelist); err != nil {
1610+
return err
1611+
}
1612+
1613+
var tmp map[string]*Registry
1614+
if err := unmarshal(&tmp); err != nil {
1615+
return err
1616+
}
1617+
1618+
typ := reflect.TypeOf(zero)
1619+
typs := strings.Split(typ.String(), ".")[1]
1620+
for id, res := range tmp {
1621+
if res == nil {
1622+
return fmt.Errorf("Could not parse resource %s:%s", typs, id)
1623+
}
1624+
res.SetID(id)
1625+
}
1626+
1627+
*ret = tmp
1628+
return nil
1629+
}

resource/resource_list_genny.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import (
1515
"github.com/goss-org/goss/util"
1616
)
1717

18-
//go:generate genny -in=$GOFILE -out=resource_list.go gen "ResourceType=Addr,Command,DNS,File,Gossfile,Group,Package,Port,Process,Service,User,KernelParam,Mount,Interface,HTTP"
18+
//go:generate genny -in=$GOFILE -out=resource_list.go gen "ResourceType=Addr,Command,DNS,File,Gossfile,Group,Package,Port,Process,Service,User,KernelParam,Mount,Interface,HTTP,Registry"
1919
//go:generate sed -i -e "/^\\/\\/ +build genny/d" resource_list.go
2020
//go:generate sed -i -e "/^\\/\\/go:.*/d" resource_list.go
2121
//go:generate sed -i -e "s/aelsabbahy/goss-org/" resource_list.go

0 commit comments

Comments
 (0)