compliance/kibana.go (415 lines of code) (raw):
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License;
// you may not use this file except in compliance with the Elastic License.
package main
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
)
const (
apiAgentPolicyPath = "/api/fleet/agent_policies"
apiPackagePolicyPath = "/api/fleet/package_policies"
apiGetSloPath = "/s/%s/api/observability/slos"
apiGetDashboardPath = "/api/dashboards/dashboard"
apiGetDetecionRulePath = "/api/detection_engine/rules"
apiLoadPrebuiltDetectionRulesPath = "/api/detection_engine/rules/prepackaged"
defaultSpace = "default"
)
type agentPolicyRequest struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Namespace string `json:"namespace,omitempty"`
}
type agentPolicyResponse struct {
Item *agentPolicyRequest `json:"item,omitempty"`
Items []agentPolicyRequest `json:"items,omitempty"`
}
type createPackagePolicyRequest struct {
Name string `json:"name"`
PolicyID string `json:"policy_id"`
Package struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"package"`
Inputs map[string]packagePolicyInput `json:"inputs,omitempty"`
}
type packagePolicyInput struct {
Streams map[string]packagePolicyStream `json:"streams,omitempty"`
}
type packagePolicyStream struct {
Vars map[string]any `json:"vars,omitempty"`
}
type dashboardResponse struct {
Item json.RawMessage `json:"item"`
}
type sloResponse struct {
Description string `json:"description"`
ID string `json:"id"`
Enabled bool `json:"enabled"`
}
type detectionRuleResponse struct {
Description string `json:"description"`
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
}
// Kibana is a kibana client.
type Kibana struct {
Host string
Username string
Password string
client *http.Client
}
// NewKibanaClient creates a new Kibana client using environment variables for its initialization.
func NewKibanaClient() (*Kibana, error) {
var client http.Client
if caCert := elasticPackageGetEnv("CA_CERT"); caCert != "" {
certPool, err := x509.SystemCertPool()
if err != nil {
return nil, fmt.Errorf("failed to get system certificate pool: %w", err)
}
pem, err := os.ReadFile(caCert)
if err != nil {
return nil, fmt.Errorf("failed to read certificate \"%s\": %w", caCert, err)
}
if ok := certPool.AppendCertsFromPEM(pem); !ok {
return nil, fmt.Errorf("no certs were appended from \"%s\"", caCert)
}
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: certPool,
},
}
}
return &Kibana{
Host: elasticPackageGetEnv("KIBANA_HOST"),
Password: elasticPackageGetEnv("ELASTICSEARCH_PASSWORD"),
Username: elasticPackageGetEnv("ELASTICSEARCH_USERNAME"),
client: &client,
}, nil
}
// CreatePolicyForPackage creates a new policy for a package.
func (k *Kibana) CreatePolicyForPackage(name string, version string) (string, error) {
err := k.deletePackagePolicyForPackage(name)
if err != nil {
return "", fmt.Errorf("failed to delete agent policy: %w", err)
}
agentPolicy, err := k.createAgentPolicyForPackage(name)
if err != nil {
return "", fmt.Errorf("failed to create agent policy: %w", err)
}
err = k.createPackagePolicy(agentPolicy.Item.ID, name, version, "", "", "", "")
if err != nil {
return "", fmt.Errorf("failed to create package policy: %w", err)
}
return agentPolicy.Item.ID, nil
}
// CreatePolicyForPackageInputAndDataset creates a policy for a package with a custom dataset.
// XXX: Pass the path of the manifest and read input name and type from there.
func (k *Kibana) CreatePolicyForPackageInputAndDataset(name, version, templateName, inputName, inputType, dataset string) (string, error) {
err := k.deletePackagePolicyForPackage(name)
if err != nil {
return "", fmt.Errorf("failed to delete agent policy: %w", err)
}
agentPolicy, err := k.createAgentPolicyForPackage(name)
if err != nil {
return "", fmt.Errorf("failed to create agent policy: %w", err)
}
err = k.createPackagePolicy(agentPolicy.Item.ID, name, version, templateName, inputName, inputType, dataset)
if err != nil {
return "", fmt.Errorf("failed to create package policy: %w", err)
}
return agentPolicy.Item.ID, nil
}
func (k *Kibana) buildPolicyName(packageName string) string {
return "test-policy-" + packageName
}
func (k *Kibana) deletePackagePolicyForPackage(name string) error {
policy, err := k.getPolicyForName(name)
if err != nil {
return fmt.Errorf("failure while looking for policy to delete: %w", err)
}
if policy == nil {
// Nothing to do.
return nil
}
return k.deletePackagePolicy(policy.ID)
}
func (k *Kibana) deletePackagePolicy(policyID string) error {
deleteBody := fmt.Sprintf(`{"agentPolicyId": "%s"}`, policyID)
req, err := k.newRequest(http.MethodPost, apiAgentPolicyPath+"/delete", strings.NewReader(deleteBody))
if err != nil {
return err
}
resp, err := k.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body (status: %d)", resp.StatusCode)
}
return fmt.Errorf("deleting policy %q failed with status %d, body: %q", policyID, resp.StatusCode, string(respBody))
}
return nil
}
func (k *Kibana) getPolicyForName(name string) (*agentPolicyRequest, error) {
req, err := k.newRequest(http.MethodGet, apiAgentPolicyPath, nil)
if err != nil {
return nil, err
}
query := req.URL.Query()
query.Add("kuery", fmt.Sprintf(`name:"%s"`, k.buildPolicyName(name)))
req.URL.RawQuery = query.Encode()
resp, err := k.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body (status: %d)", resp.StatusCode)
}
return nil, fmt.Errorf("looking for policy failed with status %d, body: %q", resp.StatusCode, string(respBody))
}
var policiesResponse agentPolicyResponse
err = json.NewDecoder(resp.Body).Decode(&policiesResponse)
if err != nil {
return nil, err
}
if len(policiesResponse.Items) == 0 {
return nil, nil
}
return &policiesResponse.Items[0], nil
}
func (k *Kibana) createAgentPolicyForPackage(name string) (*agentPolicyResponse, error) {
agentPolicyRequest := agentPolicyRequest{
Name: k.buildPolicyName(name),
Namespace: "default",
}
body, err := json.Marshal(agentPolicyRequest)
if err != nil {
return nil, err
}
req, err := k.newRequest(http.MethodPost, apiAgentPolicyPath, bytes.NewReader(body))
if err != nil {
return nil, err
}
resp, err := k.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body (status: %d)", resp.StatusCode)
}
return nil, fmt.Errorf("request failed with status %d, body: %s", resp.StatusCode, string(respBody))
}
var agentPolicy agentPolicyResponse
err = json.NewDecoder(resp.Body).Decode(&agentPolicy)
if err != nil {
return nil, err
}
return &agentPolicy, nil
}
func (k *Kibana) createPackagePolicy(agentPolicyID, name, version, templateName, inputName, inputType, dataset string) error {
var packagePolicyRequest createPackagePolicyRequest
packagePolicyRequest.Name = name + "-test-1"
packagePolicyRequest.PolicyID = agentPolicyID
packagePolicyRequest.Package.Name = name
packagePolicyRequest.Package.Version = version
if templateName != "" && inputName != "" && inputType != "" {
policyInputName := templateName + "-" + inputType
policyStreamName := name + "." + inputName
vars := make(map[string]any)
if dataset != "" {
vars["data_stream.dataset"] = dataset
}
packagePolicyRequest.Inputs = map[string]packagePolicyInput{
policyInputName: packagePolicyInput{
Streams: map[string]packagePolicyStream{
policyStreamName: packagePolicyStream{
Vars: vars,
},
},
},
}
}
body, err := json.Marshal(packagePolicyRequest)
if err != nil {
return err
}
req, err := k.newRequest(http.MethodPost, apiPackagePolicyPath, bytes.NewReader(body))
if err != nil {
return err
}
resp, err := k.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body (status: %d)", resp.StatusCode)
}
return fmt.Errorf("request failed with status %d, body: %s", resp.StatusCode, string(respBody))
}
return nil
}
// MustExistSLO checks if an SLO with the given ID exists.
func (k *Kibana) MustExistSLO(sloID string) error {
_, err := k.getSLO(sloID, defaultSpace)
if err != nil {
return err
}
return nil
}
func (k *Kibana) getSLO(sloID, space string) (*sloResponse, error) {
apiPath := fmt.Sprintf(apiGetSloPath, space)
apiPath, err := url.JoinPath(apiPath, sloID)
if err != nil {
return nil, err
}
req, err := k.newRequest(http.MethodGet, apiPath, nil)
if err != nil {
return nil, err
}
resp, err := k.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body (status: %d)", resp.StatusCode)
}
return nil, fmt.Errorf("request failed with status %d, body: %s", resp.StatusCode, string(respBody))
}
var slo sloResponse
err = json.NewDecoder(resp.Body).Decode(&slo)
if err != nil {
return nil, err
}
return &slo, nil
}
// MustExistDashboard checks if a dashboard with the given ID exists.
func (k *Kibana) MustExistDashboard(dashboardID string) error {
_, err := k.getDashboard(dashboardID)
if err != nil {
return err
}
return nil
}
func (k *Kibana) getDashboard(dashboardID string) (*dashboardResponse, error) {
apiPath, err := url.JoinPath(apiGetDashboardPath, dashboardID)
if err != nil {
return nil, err
}
req, err := k.newRequest(http.MethodGet, apiPath, nil)
if err != nil {
return nil, err
}
resp, err := k.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body (status: %d)", resp.StatusCode)
}
return nil, fmt.Errorf("request failed with status %d, body: %s", resp.StatusCode, string(respBody))
}
var dashboard dashboardResponse
err = json.NewDecoder(resp.Body).Decode(&dashboard)
if err != nil {
return nil, err
}
return &dashboard, nil
}
// MustExistDetectionRule checks if a detection rule with the given ID exists.
func (k *Kibana) MustExistDetectionRule(detectionRuleID string) error {
_, err := k.getDetectionRuleID(detectionRuleID)
if err != nil {
return err
}
return nil
}
// LoadPrebuiltDetectionRules retrieves rule statuses and loads Elastic prebuilt detection rules.
func (k *Kibana) LoadPrebuiltDetectionRules() error {
req, err := k.newRequest(http.MethodPut, apiLoadPrebuiltDetectionRulesPath, nil)
if err != nil {
return err
}
resp, err := k.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body (status: %d)", resp.StatusCode)
}
return fmt.Errorf("request failed with status %d, body: %s", resp.StatusCode, string(respBody))
}
return nil
}
func (k *Kibana) getDetectionRuleID(detectionRuleID string) (*detectionRuleResponse, error) {
apiPath, err := url.Parse(apiGetDetecionRulePath)
if err != nil {
return nil, err
}
req, err := k.newRequest(http.MethodGet, apiPath.String(), nil)
if err != nil {
return nil, err
}
params := map[string]string{
"rule_id": detectionRuleID,
}
req = addRequestParams(req, params)
resp, err := k.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body (status: %d)", resp.StatusCode)
}
return nil, fmt.Errorf("request failed with status %d, body: %s", resp.StatusCode, string(respBody))
}
var detectionRule detectionRuleResponse
err = json.NewDecoder(resp.Body).Decode(&detectionRule)
if err != nil {
return nil, err
}
return &detectionRule, nil
}
func (k *Kibana) newRequest(method string, path string, body io.Reader) (*http.Request, error) {
urlPath, err := url.JoinPath(k.Host, path)
if err != nil {
return nil, err
}
req, err := http.NewRequest(method, urlPath, body)
if err != nil {
return nil, err
}
req.SetBasicAuth(k.Username, k.Password)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("kbn-xsrf", "package-spec")
return req, nil
}
func addRequestParams(request *http.Request, params map[string]string) *http.Request {
values := request.URL.Query()
for key, value := range params {
values.Add(key, value)
}
request.URL.RawQuery = values.Encode()
return request
}