Skip to content

Commit 66afea7

Browse files
tas50claude
andcommitted
⭐ Add jamf provider
Adds a new Jamf Pro provider to mql, enabling queries against Jamf-managed infrastructure via the Jamf Pro API. Authentication uses OAuth2 client credentials (flags or JAMF_CLIENT_ID / JAMF_CLIENT_SECRET / JAMF_INSTANCE_DOMAIN env vars). Resources: - jamf — root, exposes computerInventory, computerInventoryCount, packages, sso, version, users, computerGroups - jamf.computer — computer inventory record (hardware, OS, security posture, enrollment state, local user accounts) - jamf.computer.localUserAccount — local user account on a managed Mac - jamf.package — software package definition - jamf.ssoSettings — SSO configuration (singleton) - jamf.computerGroup — smart or static computer group - jamf.user — Jamf user directory entry - jamf.userByName — lookup a Jamf user by username via init() Also bumps go-git/v5 transitively in the jamf module to address GHSA-3xc5-wrhm-f963, and fixes a stale cnquery/v9 reference in the mql scan command output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a185525 commit 66afea7

26 files changed

Lines changed: 4131 additions & 0 deletions

.github/actions/spelling/expect.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ ipsetreferencestatement
279279
isos
280280
issuewild
281281
istio
282+
itunes
282283
ixgbe
283284
jamf
284285
JFi
@@ -535,6 +536,7 @@ secureboot
535536
selfservice
536537
sentinelone
537538
serde
539+
SInstall
538540
serviceconnection
539541
servicedesk
540542
serviceprincipals

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,7 @@ for {
518518
-`{platformVersion}` single-scalar config — flatten to `fargatePlatformVersion` on parent
519519
-`{hostPath, containerPath, permissions}` device / `{containerPath, size, mountOptions}` tmpfs — use `[]dict`
520520
- Every resource and field has an explicit entry in `.lr.versions`. New entries must use the **next patch version** after the provider's current version (e.g., if the provider is at `13.1.1`, new fields should be `13.1.2`). The provider's current version is in `providers/<name>/config/config.go` (look for the `Version` field). Do **not** rely on the highest version in `.lr.versions` — it may be stale from before a major version bump. The `versions` command does this automatically, but verify the result. Existing entries are never overwritten.
521+
- **Exception — brand-new, unreleased provider:** if the provider is being introduced in this PR (its `config.go` `Version` has not shipped yet), every entry in `.lr.versions` is part of the initial release and should equal that version — not `version + 1`. The "next patch" rule applies only to fields added *after* a version has shipped. For example, a new provider at `13.0.0` has all entries at `13.0.0`; a subsequent PR that adds a field bumps that one entry to `13.0.1`.
521522
- **Match SDK types faithfully:** If an SDK field is `*bool`, use `bool` in `.lr` and `llx.BoolDataPtr()` in Go — don't cast it to `string`. If an SDK enum has only two states (Enabled/Disabled), prefer `bool`. Use `*type` intermediate variables with `llx.*DataPtr` helpers to preserve nil semantics.
522523
- **Consistency with existing fields:** Before adding new fields to a resource, check how its existing fields handle pointers, nil checks, and type conversions. Follow the same pattern.
523524
- **Verify enum values in `.lr` comments:** When listing possible values in field comments, check the SDK/API docs for completeness — don't assume the set is closed.

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ PROVIDERS := \
217217
hetzner \
218218
ipinfo \
219219
ipmi \
220+
jamf \
220221
k8s \
221222
kustomize \
222223
mondoo \

providers/defaults.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,21 @@ var DefaultProviders Providers = map[string]*Provider{
351351
},
352352
},
353353

354+
"jamf": {
355+
Provider: &plugin.Provider{
356+
Name: "jamf",
357+
ID: "go.mondoo.com/mql/providers/jamf",
358+
ConnectionTypes: []string{"jamf"},
359+
Connectors: []plugin.Connector{
360+
{
361+
Name: "jamf",
362+
Use: "jamf",
363+
Short: "a Jamf Pro instance",
364+
},
365+
},
366+
},
367+
},
368+
354369
"junos": {
355370
Provider: &plugin.Provider{
356371
Name: "junos",

providers/jamf/config/config.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright Mondoo, Inc. 2024, 2026
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package config
5+
6+
import (
7+
"go.mondoo.com/mql/v13/providers-sdk/v1/inventory"
8+
"go.mondoo.com/mql/v13/providers-sdk/v1/plugin"
9+
"go.mondoo.com/mql/v13/providers/jamf/provider"
10+
)
11+
12+
var Config = plugin.Provider{
13+
Name: "jamf",
14+
ID: "go.mondoo.com/mql/providers/jamf",
15+
Version: "13.0.0",
16+
ConnectionTypes: []string{provider.ConnectionType},
17+
Connectors: []plugin.Connector{
18+
{
19+
Name: "jamf",
20+
Use: "jamf",
21+
Short: "a Jamf Pro account",
22+
Long: `Use the Jamf provider to query a Jamf Pro instance.
23+
24+
To access the Jamf Pro API, you need your instance domain and API credentials.
25+
26+
Examples:
27+
mql shell jamf --client-id <your-client-id> --client-secret <your-client-secret> --instance-domain https://yourdomain.jamfcloud.com
28+
cnspec scan jamf --client-id <your-client-id> --client-secret <your-client-secret> --instance-domain https://yourdomain.jamfcloud.com
29+
`,
30+
Flags: []plugin.Flag{
31+
{
32+
Long: "client-id",
33+
Type: plugin.FlagType_String,
34+
Default: "",
35+
Desc: "Jamf Pro API client ID",
36+
},
37+
{
38+
Long: "client-secret",
39+
Type: plugin.FlagType_String,
40+
Default: "",
41+
Desc: "Jamf Pro API client secret",
42+
Option: plugin.FlagOption_Password,
43+
ConfigEntry: "-",
44+
},
45+
{
46+
Long: "instance-domain",
47+
Type: plugin.FlagType_String,
48+
Default: "",
49+
Desc: "Jamf Pro domain (e.g., https://yourdomain.jamfcloud.com)",
50+
},
51+
},
52+
},
53+
},
54+
AssetUrlTrees: []*inventory.AssetUrlBranch{
55+
{
56+
PathSegments: []string{"technology=saas", "provider=jamf"},
57+
Key: "kind",
58+
Title: "Kind",
59+
Values: map[string]*inventory.AssetUrlBranch{
60+
"api": nil,
61+
},
62+
},
63+
},
64+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright Mondoo, Inc. 2024, 2026
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package connection
5+
6+
import (
7+
"errors"
8+
"strings"
9+
"sync"
10+
11+
"github.com/deploymenttheory/go-api-sdk-jamfpro/sdk/jamfpro"
12+
"github.com/rs/zerolog/log"
13+
"go.mondoo.com/mql/v13/providers-sdk/v1/inventory"
14+
"go.mondoo.com/mql/v13/providers-sdk/v1/plugin"
15+
"go.mondoo.com/mql/v13/providers-sdk/v1/vault"
16+
)
17+
18+
type JamfConnection struct {
19+
plugin.Connection
20+
Conf *inventory.Config
21+
asset *inventory.Asset
22+
Client *jamfpro.Client
23+
24+
// localUserAccounts caches per-computer local user accounts from the
25+
// initial inventory fetch, keyed by computer ID. This avoids N+1 API
26+
// calls when iterating computerInventory then accessing localUserAccounts.
27+
localUserAccountsMu sync.RWMutex
28+
localUserAccounts map[string][]jamfpro.ComputerInventorySubsetLocalUserAccount
29+
}
30+
31+
func NewJamfConnection(id uint32, asset *inventory.Asset, conf *inventory.Config) (*JamfConnection, error) {
32+
conn := &JamfConnection{
33+
Connection: plugin.NewConnection(id, asset),
34+
Conf: conf,
35+
asset: asset,
36+
localUserAccounts: make(map[string][]jamfpro.ComputerInventorySubsetLocalUserAccount),
37+
}
38+
39+
// Extract credentials and options from conf
40+
var clientID, clientSecret, instanceDomain string
41+
for _, cred := range conf.Credentials {
42+
if cred.Type == vault.CredentialType_password {
43+
clientID = cred.User
44+
clientSecret = string(cred.Secret)
45+
}
46+
}
47+
if domain, ok := conf.Options["instance_domain"]; ok {
48+
instanceDomain = domain
49+
}
50+
51+
// Validate that all necessary credentials are provided
52+
if instanceDomain == "" || clientID == "" || clientSecret == "" {
53+
return nil, errors.New("missing required Jamf credentials: instance_domain, client_id, client_secret")
54+
}
55+
56+
// Create the configuration container
57+
config := &jamfpro.ConfigContainer{
58+
LogLevel: "warn",
59+
InstanceDomain: instanceDomain,
60+
AuthMethod: "oauth2",
61+
ClientID: clientID,
62+
ClientSecret: clientSecret,
63+
}
64+
65+
// Initialize the Jamf Pro client with the given configuration
66+
client, err := jamfpro.BuildClient(config)
67+
if err != nil {
68+
return nil, err
69+
}
70+
conn.Client = client
71+
log.Info().Msg("jamf> client initialized using BuildClient with ConfigContainer")
72+
73+
return conn, nil
74+
}
75+
76+
func (j *JamfConnection) Name() string {
77+
return "jamf"
78+
}
79+
80+
func (j *JamfConnection) Asset() *inventory.Asset {
81+
return j.asset
82+
}
83+
84+
func (j *JamfConnection) PlatformInfo() (*inventory.Platform, error) {
85+
return &inventory.Platform{
86+
Name: "jamf",
87+
Title: "Jamf Pro",
88+
Family: []string{"jamf"},
89+
Kind: "api",
90+
Runtime: "jamf",
91+
TechnologyUrlSegments: []string{"api", "jamf"},
92+
}, nil
93+
}
94+
95+
func (j *JamfConnection) Identifier() string {
96+
domain := j.Conf.Options["instance_domain"]
97+
if i := strings.Index(domain, "://"); i >= 0 {
98+
domain = domain[i+3:]
99+
}
100+
if i := strings.IndexAny(domain, "/?#"); i >= 0 {
101+
domain = domain[:i]
102+
}
103+
return "//platformid.api.mondoo.app/runtime/jamf/" + strings.ToLower(domain)
104+
}
105+
106+
// CacheLocalUserAccounts stores local user accounts for a computer ID,
107+
// populated during the initial inventory fetch.
108+
func (j *JamfConnection) CacheLocalUserAccounts(computerID string, accounts []jamfpro.ComputerInventorySubsetLocalUserAccount) {
109+
j.localUserAccountsMu.Lock()
110+
defer j.localUserAccountsMu.Unlock()
111+
j.localUserAccounts[computerID] = accounts
112+
}
113+
114+
// GetCachedLocalUserAccounts retrieves cached local user accounts for a
115+
// computer ID. Returns nil, false if no cache entry exists.
116+
func (j *JamfConnection) GetCachedLocalUserAccounts(computerID string) ([]jamfpro.ComputerInventorySubsetLocalUserAccount, bool) {
117+
j.localUserAccountsMu.RLock()
118+
defer j.localUserAccountsMu.RUnlock()
119+
accounts, ok := j.localUserAccounts[computerID]
120+
return accounts, ok
121+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright Mondoo, Inc. 2024, 2026
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package connection
5+
6+
import (
7+
"testing"
8+
9+
"github.com/deploymenttheory/go-api-sdk-jamfpro/sdk/jamfpro"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
"go.mondoo.com/mql/v13/providers-sdk/v1/inventory"
13+
"go.mondoo.com/mql/v13/providers-sdk/v1/vault"
14+
)
15+
16+
func TestNewJamfConnection_MissingCredentials(t *testing.T) {
17+
_, err := NewJamfConnection(0, &inventory.Asset{}, &inventory.Config{
18+
Options: map[string]string{},
19+
})
20+
require.Error(t, err)
21+
assert.Contains(t, err.Error(), "missing required Jamf credentials")
22+
}
23+
24+
func TestNewJamfConnection_MissingDomain(t *testing.T) {
25+
cred := &vault.Credential{
26+
Type: vault.CredentialType_password,
27+
User: "client-id",
28+
Secret: []byte("client-secret"),
29+
}
30+
_, err := NewJamfConnection(0, &inventory.Asset{}, &inventory.Config{
31+
Options: map[string]string{},
32+
Credentials: []*vault.Credential{cred},
33+
})
34+
require.Error(t, err)
35+
assert.Contains(t, err.Error(), "missing required Jamf credentials")
36+
}
37+
38+
func TestNewJamfConnection_MissingClientID(t *testing.T) {
39+
_, err := NewJamfConnection(0, &inventory.Asset{}, &inventory.Config{
40+
Options: map[string]string{
41+
"instance_domain": "https://example.jamfcloud.com",
42+
},
43+
})
44+
require.Error(t, err)
45+
assert.Contains(t, err.Error(), "missing required Jamf credentials")
46+
}
47+
48+
func TestJamfConnection_PlatformInfo(t *testing.T) {
49+
conn := &JamfConnection{}
50+
platform, err := conn.PlatformInfo()
51+
require.NoError(t, err)
52+
assert.Equal(t, "jamf", platform.Name)
53+
assert.Equal(t, "Jamf Pro", platform.Title)
54+
assert.Equal(t, "api", platform.Kind)
55+
assert.Equal(t, "jamf", platform.Runtime)
56+
assert.Equal(t, []string{"jamf"}, platform.Family)
57+
}
58+
59+
func TestJamfConnection_Identifier(t *testing.T) {
60+
conn := &JamfConnection{
61+
Conf: &inventory.Config{
62+
Options: map[string]string{
63+
"instance_domain": "https://MyCompany.jamfcloud.com",
64+
},
65+
},
66+
}
67+
id := conn.Identifier()
68+
assert.Equal(t, "//platformid.api.mondoo.app/runtime/jamf/mycompany.jamfcloud.com", id)
69+
}
70+
71+
func TestJamfConnection_IdentifierBareHostname(t *testing.T) {
72+
conn := &JamfConnection{
73+
Conf: &inventory.Config{
74+
Options: map[string]string{
75+
"instance_domain": "MyCompany.jamfcloud.com",
76+
},
77+
},
78+
}
79+
id := conn.Identifier()
80+
assert.Equal(t, "//platformid.api.mondoo.app/runtime/jamf/mycompany.jamfcloud.com", id)
81+
}
82+
83+
func TestJamfConnection_IdentifierStripsPath(t *testing.T) {
84+
conn := &JamfConnection{
85+
Conf: &inventory.Config{
86+
Options: map[string]string{
87+
"instance_domain": "https://mycompany.jamfcloud.com/extra/path",
88+
},
89+
},
90+
}
91+
id := conn.Identifier()
92+
assert.Equal(t, "//platformid.api.mondoo.app/runtime/jamf/mycompany.jamfcloud.com", id)
93+
}
94+
95+
func TestJamfConnection_Name(t *testing.T) {
96+
conn := &JamfConnection{}
97+
assert.Equal(t, "jamf", conn.Name())
98+
}
99+
100+
func TestJamfConnection_LocalUserAccountsCache(t *testing.T) {
101+
conn := &JamfConnection{
102+
localUserAccounts: make(map[string][]jamfpro.ComputerInventorySubsetLocalUserAccount),
103+
}
104+
105+
// Cache should be empty initially
106+
_, ok := conn.GetCachedLocalUserAccounts("123")
107+
assert.False(t, ok)
108+
109+
// After caching, should return data
110+
testAccounts := []jamfpro.ComputerInventorySubsetLocalUserAccount{
111+
{UID: "501", Username: "admin", Admin: true},
112+
{UID: "502", Username: "user1", Admin: false},
113+
}
114+
conn.CacheLocalUserAccounts("123", testAccounts)
115+
116+
cached, ok := conn.GetCachedLocalUserAccounts("123")
117+
assert.True(t, ok)
118+
require.Len(t, cached, 2)
119+
assert.Equal(t, "admin", cached[0].Username)
120+
assert.True(t, cached[0].Admin)
121+
assert.Equal(t, "user1", cached[1].Username)
122+
assert.False(t, cached[1].Admin)
123+
124+
// Different ID should miss cache
125+
_, ok = conn.GetCachedLocalUserAccounts("456")
126+
assert.False(t, ok)
127+
}

providers/jamf/gen/main.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copyright Mondoo, Inc. 2024, 2026
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package main
5+
6+
import (
7+
"go.mondoo.com/mql/v13/providers-sdk/v1/plugin/gen"
8+
"go.mondoo.com/mql/v13/providers/jamf/config"
9+
)
10+
11+
func main() {
12+
gen.CLI(&config.Config)
13+
}

0 commit comments

Comments
 (0)