Contents

Tutorials

How to Use Munki Conditions With Osquery

A common capability sought after by MacAdmins, is a method to make changes or install software on a specific subset of Macs. In some cases, this subset is static and easily defined; in other cases, installation of new software hinges on a dynamic property of the device (eg. its physical location when installing printers). Ideally, these dynamic properties could be monitored and used as conditional triggers for the installation of software.

To address this issue at Kolide, we use the open-source tool Munki (from Walt Disney Animation Studios). Munki works seamlessly with just about any Mac "installable" type there is and features a number of powerful customization options. Among these options, is a feature called Conditional Items which allows us to install or remove software if certain conditions are met on a Mac.

Remember our printer example? In order to automatically install the appropriate printers, a Conditional Item could be created which looked at the LAN_subnet on the device. This way an employee traveling between different offices would get the correct set of printers at each location.

This dynamic approach to software management is terrifically powerful, but initially limited by the small number of pre-built conditions that ship with Munki (hostname, os_vers, machine_model and serial_number). Thankfully however, there is a mechanism for administrators to create custom conditions and we can use tools like Osquery to radically expand what's possible.

To create a new conditional item, an administrator writes a script which creates a key/value pair(or array of values) and saves it in /Library/Managed Installs/ConditionalItems.plist. Usually that's a bash or python script to query some part of the system and create a new condition. But, if we already have an osquery process running, we can gain access to a wide variety of facts about the Mac with the power of a simple SQL query. Allister Banks, a fellow MacAdmin and osquery user suggested I could write this integration with using the osquery-python bindings, but we were developing osquery-go, so I thought it would be a good opportunity to test the Go API.

An image of the blue gopher, a symbol of the Go programming language, wearing a Kolide hat and reading from a pamphlet with the osquery logo on it

Osquery-go — Thrift bindings for Go programs to interact with osquery

I wanted to take the opportunity here and show Munki users how they can enhance their Munki configuration with osquery, and also provide a hands-on introduction to osquery-go, our Go SDK for writing plugins and interacting with the osquery daemon. Let's dive in!

Creating the ConditionalItems Plist

The Munki documentation says:

Each "write" to ConditionalItems.plist must contain a key/value pair. A value may also be an 'array' of values, similar to the built-in conditions 'ipv4_address' or 'catalogs'.

In Go we could represent this data structure with a new type.

type MunkiConditions map[string][]string

We will also need to read and write the plist file, so let's create these as helper methods.

Note: To keep the blog post short and readable I will abbreviate some of the code samples. The full implementation is linked at the end of the post.

func (c *MunkiConditions) Load() error { ... }
func (c *MunkiConditions) Save() error { ... }

Interfacing with Osquery

Osquery provides a powerful API for external plugins using Apache Thrift. The Thrift API can be used to implement custom loggers, tables, configuration plugins or to run queries. Today we'll explore how to call the Query method using the oquery-go client.

First, we need a to create a client.

// The path to the osquery unix domain socket.
socketPath := "/var/osquery/osquery.em"
// Create a client, with a 10 second timeout in case the socket is not available.
client, err := osquery.NewClient(socketPath, 10*time.Second)

The returned client looks like this:

type ExtensionManagerClient struct { ... }
func (p *ExtensionManagerClient) Query(sql string) (r *ExtensionResponse, err error)

So now we could run:

extensionResponse, err := client.Query("select version from osquery_info;")
response := extensionResponse.GetResponse()

Which will return the following type:

type ExtensionPluginResponse []map[string]string

The map corresponds to an array of osquery table output.

Converting ExtensionPluginResponse to MunkiConditions

Now that we know how to update the ConditionalItems.plist file and run queries programmatically, all we need is some glue code to convert the ExtensionPluginResponse type into the MunkiConditions type.

conditions := make(MunkiConditions)
for row := range response {
    for key, value := range row {
        // add osquery_ suffix to avoid namespace collisions
        conditions[fmt.Sprintf("osquery_%s", key)] = []string{value}
    }
}

Run it concurrently

You probably want to run many queries to generate the ConditionalItems file. Unfortunately the Thrift API won't run the queries in parallel, but we can at least use Go's concurrency features to schedule all our queries asynchronously.

// OsqueryClient wraps the extension client.
type OsqueryClient struct {
 *osquery.ExtensionManagerClient
}
// RunQueries takes one or more SQL queries and returns a channel with all the responses.
func (c *OsqueryClient) RunQueries(queries ...string) (<-chan map[string]string, error) {
 responses := make(chan map[string]string)
// schedule the queries in a separate goroutine
 // it doesn't wait for the responses to return.
 go func() {
  for _, q := range queries {
   resp, err := c.Query(q)
   if err != nil {
    log.Println(err)
    return
   }
   if resp.Status.Code != 0 {
    log.Printf("got status %d\n", resp.Status.Code)
    return
   }
   for _, r := range resp.Response {
    responses <- r
   }
  }
  // close the response channel when all queries finish running.
  close(responses)
 }()
 return responses, nil
}

Putting it all together

To write our conditions script we can do the following:

var conditions MunkiConditions
err := conditions.Load()
extensionClient, err := osquery.NewClient(socketPath, 10*time.Second)
defer client.Close()
client := &OsqueryClient{extensionClient}
responses, err := client.RunQueries(...)
for row := range response {
    for key, value := range row {
        conditions[fmt.Sprintf("osquery_%s", key)] = []string{value}
    }
}
err := conditions.Save()

An image of a terminal window showing the build script running

You can find the full implementation of the osquery-condition utility at https://github.com/groob/osquery-condition

Share this story:

More articles you
might enjoy:

Tutorials
How to Manage Osquery With Kolide Launcher and Fleet
Kolide
Tutorials
How to Monitor macOS Hosts With Osquery
Kolide
Inside Kolide
How We Securely Autoupdate Osquery at Kolide
Kolide
Try Kolide Free
Try Kolide Free