Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 54 additions & 21 deletions cmd/main.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Utility to add rules from Moroz or Rudolph to a Workshop instance.
// Utility to add rules from Moroz, Rudolph, or Zentral to a Workshop instance.
// Copyright (c) 2025 North Pole Security, Inc.
package main

Expand All @@ -14,6 +14,7 @@ import (
"github.com/northpolesec/santa-rule-importer/internal/morozconfig"
"github.com/northpolesec/santa-rule-importer/internal/rudolph"
"github.com/northpolesec/santa-rule-importer/internal/santactl"
"github.com/northpolesec/santa-rule-importer/internal/zentral"

"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
Expand All @@ -26,60 +27,92 @@ import (
func usage() {
fmt.Fprintf(os.Stderr, "Usage: %s [OPTIONS] <path to config.toml|path to config.csv> <server>\n", os.Args[0])
fmt.Fprintln(os.Stderr)
fmt.Fprintf(os.Stderr, "santa-rule-importer - tool to import rules from Moroz and Rudolph to Workshop\n")
fmt.Fprintf(os.Stderr, "santa-rule-importer - tool to import rules from Moroz, Rudolph, and Zentral to Workshop\n")
fmt.Fprintln(os.Stderr)
fmt.Fprintf(os.Stderr, "This tool expects the Workshop API Key to be in the WORKSHOP_API_KEY env var\n")
fmt.Fprintf(os.Stderr, "For Zentral imports, set ZENTRAL_API_KEY env var with your Zentral API token\n")
fmt.Fprintln(os.Stderr)
flag.PrintDefaults()
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, " Example Usage:")
fmt.Fprintf(os.Stderr, "\t%s global.toml nps.workshop.cloud\n", os.Args[0])
fmt.Fprintf(os.Stderr, "\t%s --zentral-url zentral.example.com nps.workshop.cloud\n", os.Args[0])
os.Exit(1)
}

func main() {
useInsecure := flag.Bool("insecure", false, "Use insecure connection")
useCustomMsgAsComment := flag.Bool("use-custom-msg-as-comment", false, "Use custom message as comment (moroz only)")
zentBaseURL := flag.String("zentral-url", "", "Zentral base URL (e.g., zentral.example.com)")
zentTargetType := flag.String("zentral-target-type", "", "Filter Zentral rules by target type (BINARY, CERTIFICATE, etc.)")
zentTargetIdentifier := flag.String("zentral-target-identifier", "", "Filter Zentral rules by target identifier")
zentConfigID := flag.Int("zentral-config-id", 0, "Filter Zentral rules by configuration ID")

flag.Usage = usage
flag.Parse()

args := flag.Args()
// Check if a filename and server addr was provided as an argument
if len(args) < 2 {
usage()
}

apiKey := os.Getenv("WORKSHOP_API_KEY")
if apiKey == "" {
println("Please set WORKSHOP_API_KEY environment variable with your API key.")
os.Exit(1)
}

filename := args[0]
server := args[1]

var (
rules []*apipb.Rule
ruleSrcErr error
server string
)

// Check the file extension and parse CSVs from rudolph or TOML files from
// moroz.
if strings.HasSuffix(filename, ".csv") {
rules, ruleSrcErr = rudolph.ParseRulesFromFile(filename)
} else if strings.HasSuffix(filename, ".toml") {
// Read the file content
rules, ruleSrcErr = morozconfig.ParseRulesFromFile(filename, *useCustomMsgAsComment)
} else if strings.HasSuffix(filename, ".json") {
rules, ruleSrcErr = santactl.ParseRulesFromFile(filename)
// Check if using Zentral API or file input
if *zentBaseURL != "" {
// Handle Zentral API import
if len(args) < 1 {
println("Server address required for Zentral imports.")
usage()
}
server = args[0]

zentAPIKey := os.Getenv("ZENTRAL_API_KEY")
if zentAPIKey == "" {
println("Please set ZENTRAL_API_KEY environment variable for Zentral imports.")
os.Exit(1)
}

baseURL := *zentBaseURL
if !strings.HasPrefix(baseURL, "http") {
baseURL = "https://" + baseURL
}

rules, ruleSrcErr = zentral.GetRulesFromZentral(baseURL, zentAPIKey, *zentTargetType, *zentTargetIdentifier, *zentConfigID)
} else {
println("Unsupported file format. Please provide a .toml or .csv file.")
os.Exit(1)
// Handle file input
if len(args) < 2 {
usage()
}
filename := args[0]
server = args[1]

// Check the file extension and parse CSVs from rudolph or TOML files from moroz.
if strings.HasSuffix(filename, ".csv") {
rules, ruleSrcErr = rudolph.ParseRulesFromFile(filename)
} else if strings.HasSuffix(filename, ".toml") {
rules, ruleSrcErr = morozconfig.ParseRulesFromFile(filename, *useCustomMsgAsComment)
} else if strings.HasSuffix(filename, ".json") {
rules, ruleSrcErr = santactl.ParseRulesFromFile(filename)
} else {
println("Unsupported file format. Please provide a .toml, .csv, or .json file.")
os.Exit(1)
}
}

if ruleSrcErr != nil {
log.Fatalf("Failed to read config file: %s %v", filename, ruleSrcErr)
if *zentBaseURL != "" {
log.Fatalf("Failed to retrieve rules from Zentral: %v", ruleSrcErr)
} else {
log.Fatalf("Failed to read config file: %v", ruleSrcErr)
}
}

opts := []grpc.DialOption{
Expand Down
3 changes: 3 additions & 0 deletions internal/zentral/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Package zentral provides functionality to retrieve Santa rules from Zentral
// and convert them to Workshop format.
package zentral
29 changes: 29 additions & 0 deletions internal/zentral/testdata/zentral_paginated_page1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"count": 5,
"next": "https://zentral.example.com/api/santa/rules/?page=2",
"previous": null,
"results": [
{
"id": 1,
"target_type": "BINARY",
"target_identifier": "hash1",
"policy": "BLOCKLIST",
"custom_msg": "Test rule 1",
"description": "First test rule",
"configuration": 1,
"created_at": "2025-01-01T10:00:00Z",
"updated_at": "2025-01-01T10:00:00Z"
},
{
"id": 2,
"target_type": "CERTIFICATE",
"target_identifier": "cert1",
"policy": "ALLOWLIST",
"custom_msg": "Test rule 2",
"description": "Second test rule",
"configuration": 1,
"created_at": "2025-01-01T11:00:00Z",
"updated_at": "2025-01-01T11:00:00Z"
}
]
}
18 changes: 18 additions & 0 deletions internal/zentral/testdata/zentral_paginated_page2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"count": 5,
"next": null,
"previous": "https://zentral.example.com/api/santa/rules/?page=1",
"results": [
{
"id": 3,
"target_type": "TEAMID",
"target_identifier": "team1",
"policy": "ALLOWLIST",
"custom_msg": "Test rule 3",
"description": "Third test rule",
"configuration": 2,
"created_at": "2025-01-01T12:00:00Z",
"updated_at": "2025-01-01T12:00:00Z"
}
]
}
40 changes: 40 additions & 0 deletions internal/zentral/testdata/zentral_rules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"count": 3,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"target_type": "BINARY",
"target_identifier": "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3",
"policy": "BLOCKLIST",
"custom_msg": "Malicious binary detected",
"description": "Known malware hash from threat intel",
"configuration": 123,
"created_at": "2025-01-01T10:00:00Z",
"updated_at": "2025-01-01T10:00:00Z"
},
{
"id": 2,
"target_type": "CERTIFICATE",
"target_identifier": "59FB936AC5B6FED8A00F7F8CFBCD5A5A6F4F7D9A5D2E8A8A8A8A8A8A8A8A8A8A",
"policy": "ALLOWLIST",
"custom_msg": "Trusted certificate",
"description": "Certificate for approved application",
"configuration": 123,
"created_at": "2025-01-01T11:00:00Z",
"updated_at": "2025-01-01T11:00:00Z"
},
{
"id": 3,
"target_type": "TEAMID",
"target_identifier": "ABCDEF1234",
"policy": "ALLOWLIST",
"custom_msg": "",
"description": "Apple Developer Team ID",
"configuration": 456,
"created_at": "2025-01-01T12:00:00Z",
"updated_at": "2025-01-01T12:00:00Z"
}
]
}
175 changes: 175 additions & 0 deletions internal/zentral/zentral.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package zentral

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"

"github.com/northpolesec/santa-rule-importer/internal/rulehelpers"

apipb "buf.build/gen/go/northpolesec/workshop-api/protocolbuffers/go/workshop/v1"
)

// Rule represents a Santa rule from Zentral API response
type Rule struct {
ID int `json:"id"`
TargetType string `json:"target_type"`
TargetIdentifier string `json:"target_identifier"`
Policy string `json:"policy"`
CustomMsg string `json:"custom_msg"`
Description string `json:"description"`
ConfigurationID int `json:"configuration"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}

// APIResponse represents the paginated response from Zentral API
type APIResponse struct {
Count int `json:"count"`
Next *string `json:"next"`
Previous *string `json:"previous"`
Results []Rule `json:"results"`
}

// Client represents a Zentral API client
type Client struct {
BaseURL string
Token string
HTTPClient *http.Client
}

// NewClient creates a new Zentral API client
func NewClient(baseURL, token string) *Client {
return &Client{
BaseURL: baseURL,
Token: token,
HTTPClient: &http.Client{},
}
}

// makeRequest makes an authenticated HTTP request to the Zentral API
func (c *Client) makeRequest(endpoint string) (*http.Response, error) {
// Parse base URL
baseURL, err := url.Parse(c.BaseURL)
if err != nil {
return nil, fmt.Errorf("failed to parse base URL: %w", err)
}

// Parse endpoint (which may contain query parameters)
endpointURL, err := url.Parse(endpoint)
if err != nil {
return nil, fmt.Errorf("failed to parse endpoint: %w", err)
}

// Resolve the endpoint against the base URL
fullURL := baseURL.ResolveReference(endpointURL)

req, err := http.NewRequest("GET", fullURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

req.Header.Set("Authorization", "Token "+c.Token)
req.Header.Set("Content-Type", "application/json")

resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}

if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}

return resp, nil
}

// GetRules retrieves all Santa rules from Zentral API with optional filters
func (c *Client) GetRules(targetType, targetIdentifier string, configurationID int) ([]Rule, error) {
var allRules []Rule
endpoint := "/api/santa/rules/"

// Add query parameters if provided
params := url.Values{}
if targetType != "" {
params.Set("target_type", targetType)
}
if targetIdentifier != "" {
params.Set("target_identifier", targetIdentifier)
}
if configurationID > 0 {
params.Set("configuration_id", strconv.Itoa(configurationID))
}

if len(params) > 0 {
endpoint += "?" + params.Encode()
}

for {
resp, err := c.makeRequest(endpoint)
if err != nil {
return nil, err
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}

var apiResp APIResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse API response: %w", err)
}

allRules = append(allRules, apiResp.Results...)

// Check if there are more pages
if apiResp.Next == nil {
break
}

// Parse the next URL to get just the path and query
nextURL, err := url.Parse(*apiResp.Next)
if err != nil {
return nil, fmt.Errorf("failed to parse next URL: %w", err)
}
endpoint = nextURL.Path + "?" + nextURL.RawQuery
}

return allRules, nil
}

// ConvertToWorkshopRules converts Zentral rules to Workshop format
func ConvertToWorkshopRules(zenRules []Rule) []*apipb.Rule {
rules := make([]*apipb.Rule, len(zenRules))

for i, zenRule := range zenRules {
rules[i] = &apipb.Rule{
RuleType: rulehelpers.GetRuleType(zenRule.TargetType),
Policy: rulehelpers.GetPolicyType(zenRule.Policy),
Identifier: zenRule.TargetIdentifier,
CustomMsg: zenRule.CustomMsg,
Comment: zenRule.Description,
}
}

return rules
}

// GetRulesFromZentral is a convenience function that fetches and converts rules
func GetRulesFromZentral(baseURL, token, targetType, targetIdentifier string, configurationID int) ([]*apipb.Rule, error) {
client := NewClient(baseURL, token)

zenRules, err := client.GetRules(targetType, targetIdentifier, configurationID)
if err != nil {
return nil, fmt.Errorf("failed to get rules from Zentral: %w", err)
}

return ConvertToWorkshopRules(zenRules), nil
}
Loading
Loading