Skip to content

Commit 0ee281b

Browse files
authored
Merge pull request #31 from Asymptote-Labs/shukan/elastic-integration
Add Elastic endpoint integration pack
2 parents bbc5b38 + 59b9666 commit 0ee281b

18 files changed

Lines changed: 1094 additions & 4 deletions

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Do not recreate or depend on removed `asymptote` mirror trees. Keep new work foc
1919
- Preserve the local-only product posture. The public Beacon build should not require a hosted account, remote policy fetch, hosted dashboard, or external network dependency during normal hook execution.
2020
- Do not add dependency vulnerability scanning, OSV/GHSA lookups, package remediation, or other vulnerability-enforcement flows to the public hook path.
2121
- Do not add broad runtime enforcement unless explicitly requested. Current control behavior is limited to hook-native approvals/denials exposed by supported agent runtimes.
22-
- Keep direct destination support scoped to local JSONL/Wazuh unless explicitly requested. Other SIEM/observability systems should consume the local output through customer-managed forwarding.
22+
- Keep direct destination support scoped to local JSONL/Wazuh unless explicitly requested. Elastic support is a file-tailing pack over local JSONL; Beacon itself must not store Elastic credentials or require a hosted Elastic dependency.
2323
- Default content retention is `full`: configured prompt text, command output, raw tool inputs, raw OTLP attributes, and raw diffs may be written to local or customer-controlled logs, still subject to local redaction and size limits where supported. Keep `metadata` and `redacted` modes available for stricter deployments.
2424

2525
## Telemetry Scope
@@ -30,12 +30,13 @@ Supported runtime surfaces today:
3030
- Cursor hook telemetry for sessions, prompt submission, tool use, command execution, MCP-like tool activity, approval decisions, and file edits where hook payloads expose those fields.
3131
- Claude Cowork admin-configured OpenTelemetry setup guidance and local validation.
3232
- `beaconjson` OpenTelemetry Collector exporter that converts OTLP logs, traces, metrics, and resource attributes into Beacon endpoint JSONL.
33+
- Elasticsearch/Filebeat content pack generation for forwarding local Beacon JSONL into customer-managed Elastic deployments or the bundled loopback-only development stack.
3334
- A local-only dashboard served by `beacon endpoint dashboard`, bound to loopback by default and backed by the runtime JSONL log.
3435

3536
Current non-goals unless explicitly requested:
3637

3738
- Kernel/process monitoring, EDR replacement, shell history scraping, cloud audit ingestion, browser/SaaS telemetry, credential-use attribution, and MCP configuration inventory.
38-
- Direct hosted integrations for Datadog, Splunk, Elastic, Snowflake, Chronicle, Panther, or other SIEM destinations.
39+
- Direct hosted integrations for Datadog, Snowflake, Chronicle, Panther, or other SIEM destinations beyond explicitly supported local/customer-managed forwarding patterns.
3940
- Dependency vulnerability scanning or package security remediation.
4041

4142
## Common Commands

README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ locally.
4040

4141
Beacon is built to be easy to deploy for Security and IT teams through
4242
[MDM deployment](https://docs.asymptotelabs.ai/cli/security-it-teams) and to
43-
connect to Wazuh, Splunk HEC, or customer-managed SIEM pipelines, while
43+
connect to Wazuh, Elastic, Splunk HEC, or customer-managed SIEM pipelines, while
4444
remaining visibility-first and local-first during normal endpoint collection.
4545

4646
## High-Level Architecture
@@ -57,7 +57,7 @@ leaving forwarding under customer control.
5757
- **Beacon endpoint layer:** Local processing normalizes events, applies
5858
retention and redaction settings, and writes durable endpoint telemetry.
5959
- **Output layer:** Teams inspect events in the local dashboard, retain JSONL,
60-
or forward records into Wazuh, Splunk HEC, and customer-managed SIEM
60+
or forward records into Wazuh, Elastic, Splunk HEC, and customer-managed SIEM
6161
pipelines.
6262

6363
Beacon filters generic process and runtime metrics, such as Node.js event loop,
@@ -82,6 +82,23 @@ records during rollout, testing, and investigations.
8282
<img src="images/dashboard-log-search.png" alt="Beacon dashboard log search" width="860">
8383
</p>
8484

85+
## Elastic
86+
87+
Beacon ships an Elastic content pack for teams that want to search endpoint
88+
events in Elasticsearch and Kibana without giving Beacon cluster credentials.
89+
The pack tails the same local `runtime.jsonl` file with Filebeat or standalone
90+
Elastic Agent, installs ECS-oriented templates and an ingest pipeline, and
91+
includes starter Kibana assets.
92+
93+
```bash
94+
beacon endpoint elastic install-pack --output ./beacon-elastic-pack
95+
beacon endpoint elastic up --pack-dir ./beacon-elastic-pack
96+
```
97+
98+
The local stack binds Elasticsearch and Kibana to loopback. Existing self-managed
99+
or Elastic Cloud deployments can use the same pack by pointing Filebeat at their
100+
cluster with `ES_HOSTS` and `ES_API_KEY`.
101+
85102
## Start Here
86103

87104
- [Beacon CLI docs](https://docs.asymptotelabs.ai/cli) — full documentation index.

cli/beacon/cmd/endpoint.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
package cmd
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"runtime"
711
"strings"
812

913
"github.com/spf13/cobra"
1014

1115
endpointconfig "github.com/asymptote-labs/agent-beacon/cli/beacon/internal/endpoint/config"
1216
"github.com/asymptote-labs/agent-beacon/cli/beacon/internal/endpoint/dashboard"
17+
"github.com/asymptote-labs/agent-beacon/cli/beacon/internal/endpoint/elastic"
1318
"github.com/asymptote-labs/agent-beacon/cli/beacon/internal/endpoint/harness"
1419
endpointhooks "github.com/asymptote-labs/agent-beacon/cli/beacon/internal/endpoint/hooks"
1520
"github.com/asymptote-labs/agent-beacon/cli/beacon/internal/endpoint/integrations/cowork"
@@ -41,6 +46,7 @@ var endpointOpts struct {
4146
coworkNgrok bool
4247
coworkOpen bool
4348
coworkSince string
49+
elasticPackDir string
4450
hookLevel string
4551
contentRetention string
4652
splunkHECEndpoint string
@@ -108,6 +114,11 @@ var endpointWazuhCmd = &cobra.Command{
108114
Short: "Manage Wazuh integration content",
109115
}
110116

117+
var endpointElasticCmd = &cobra.Command{
118+
Use: "elastic",
119+
Short: "Manage Elasticsearch integration content",
120+
}
121+
111122
var endpointIntegrationsCmd = &cobra.Command{
112123
Use: "integrations",
113124
Short: "Manage admin-configured endpoint integrations",
@@ -230,6 +241,40 @@ var endpointWazuhValidateCmd = &cobra.Command{
230241
RunE: runEndpointWazuhValidate,
231242
}
232243

244+
var endpointElasticPrintConfigCmd = &cobra.Command{
245+
Use: "print-config",
246+
Short: "Print a Filebeat input for Beacon endpoint events",
247+
Run: func(cmd *cobra.Command, args []string) {
248+
cfg := loadOrDefaultConfig()
249+
fmt.Print(elastic.InputSnippet(cfg.LogPath))
250+
},
251+
}
252+
253+
var endpointElasticInstallPackCmd = &cobra.Command{
254+
Use: "install-pack",
255+
Short: "Write Elasticsearch templates, pipeline, and Filebeat content to a directory",
256+
SilenceUsage: true,
257+
RunE: runEndpointElasticInstallPack,
258+
}
259+
260+
var endpointElasticUpCmd = &cobra.Command{
261+
Use: "up",
262+
Short: "Start a local Elasticsearch, Kibana, and Filebeat stack",
263+
SilenceUsage: true,
264+
RunE: func(cmd *cobra.Command, args []string) error {
265+
return runEndpointElasticUp(cmd.Context())
266+
},
267+
}
268+
269+
var endpointElasticDownCmd = &cobra.Command{
270+
Use: "down",
271+
Short: "Stop the local Elasticsearch stack",
272+
SilenceUsage: true,
273+
RunE: func(cmd *cobra.Command, args []string) error {
274+
return runEndpointElasticDown(cmd.Context())
275+
},
276+
}
277+
233278
func init() {
234279
rootCmd.AddCommand(endpointCmd)
235280

@@ -240,11 +285,16 @@ func init() {
240285
endpointCmd.AddCommand(endpointRepairCmd)
241286
endpointCmd.AddCommand(endpointDashboardCmd)
242287
endpointCmd.AddCommand(endpointWazuhCmd)
288+
endpointCmd.AddCommand(endpointElasticCmd)
243289
endpointCmd.AddCommand(endpointIntegrationsCmd)
244290
endpointCmd.AddCommand(endpointHooksCmd)
245291
endpointWazuhCmd.AddCommand(endpointWazuhPrintConfigCmd)
246292
endpointWazuhCmd.AddCommand(endpointWazuhInstallPackCmd)
247293
endpointWazuhCmd.AddCommand(endpointWazuhValidateCmd)
294+
endpointElasticCmd.AddCommand(endpointElasticPrintConfigCmd)
295+
endpointElasticCmd.AddCommand(endpointElasticInstallPackCmd)
296+
endpointElasticCmd.AddCommand(endpointElasticUpCmd)
297+
endpointElasticCmd.AddCommand(endpointElasticDownCmd)
248298
endpointIntegrationsCmd.AddCommand(endpointCoworkCmd)
249299
endpointHooksCmd.AddCommand(endpointHooksInstallCmd)
250300
endpointHooksCmd.AddCommand(endpointHooksUninstallCmd)
@@ -294,6 +344,14 @@ func init() {
294344
endpointWazuhValidateCmd.Flags().BoolVar(&endpointOpts.userMode, "user", true, "Use per-user endpoint paths")
295345
endpointWazuhValidateCmd.Flags().BoolVar(&endpointOpts.systemMode, "system", false, "Use system endpoint paths and launch daemon")
296346
endpointWazuhValidateCmd.Flags().StringVar(&endpointOpts.logPath, "log-path", "", "Runtime JSONL log path")
347+
for _, c := range []*cobra.Command{endpointElasticPrintConfigCmd, endpointElasticInstallPackCmd, endpointElasticUpCmd, endpointElasticDownCmd} {
348+
c.Flags().BoolVar(&endpointOpts.userMode, "user", true, "Use per-user endpoint paths")
349+
c.Flags().BoolVar(&endpointOpts.systemMode, "system", false, "Use system endpoint paths and launch daemon")
350+
c.Flags().StringVar(&endpointOpts.logPath, "log-path", "", "Runtime JSONL log path")
351+
}
352+
endpointElasticInstallPackCmd.Flags().StringVar(&endpointOpts.outputDir, "output", "", "Output directory for Elasticsearch content pack")
353+
endpointElasticUpCmd.Flags().StringVar(&endpointOpts.elasticPackDir, "pack-dir", elastic.DefaultOutputDir, "Elasticsearch pack directory")
354+
endpointElasticDownCmd.Flags().StringVar(&endpointOpts.elasticPackDir, "pack-dir", elastic.DefaultOutputDir, "Elasticsearch pack directory")
297355
for _, c := range []*cobra.Command{endpointCoworkPrintConfigCmd, endpointCoworkSetupCmd, endpointCoworkStatusCmd, endpointCoworkValidateCmd} {
298356
c.Flags().BoolVar(&endpointOpts.userMode, "user", true, "Use per-user endpoint paths")
299357
c.Flags().BoolVar(&endpointOpts.systemMode, "system", false, "Use system endpoint paths and launch daemon")
@@ -472,6 +530,123 @@ func runEndpointWazuhValidate(cmd *cobra.Command, args []string) error {
472530
return nil
473531
}
474532

533+
func runEndpointElasticInstallPack(cmd *cobra.Command, args []string) error {
534+
cfg := loadOrDefaultConfig()
535+
outputDir := endpointOpts.outputDir
536+
if outputDir == "" {
537+
outputDir = elastic.DefaultOutputDir
538+
}
539+
if err := elastic.InstallPack(outputDir, cfg.LogPath); err != nil {
540+
return err
541+
}
542+
fmt.Printf("Elasticsearch content pack written to %s\n", outputDir)
543+
return nil
544+
}
545+
546+
func runEndpointElasticUp(ctx context.Context) error {
547+
if runtime.GOOS != "darwin" {
548+
return fmt.Errorf("beacon endpoint elastic up is currently macOS-only")
549+
}
550+
cfg := loadOrDefaultConfig()
551+
logPath, err := filepath.Abs(cfg.LogPath)
552+
if err != nil {
553+
return err
554+
}
555+
packDir := endpointOpts.elasticPackDir
556+
if packDir == "" {
557+
packDir = elastic.DefaultOutputDir
558+
}
559+
if err := ensureElasticPack(packDir, logPath); err != nil {
560+
return err
561+
}
562+
if err := ensureLogFile(logPath); err != nil {
563+
return err
564+
}
565+
env := os.Environ()
566+
env = append(env, "BEACON_LOG_DIR="+filepath.Dir(logPath))
567+
if err := runDockerCompose(ctx, packDir, env, "up", "-d"); err != nil {
568+
return err
569+
}
570+
fmt.Printf("Elasticsearch ready at http://localhost:%s\n", envDefault("BEACON_ELASTIC_ES_PORT", "9200"))
571+
fmt.Printf("Kibana ready at http://localhost:%s\n", envDefault("BEACON_ELASTIC_KIBANA_PORT", "5601"))
572+
fmt.Printf("Filebeat tailing %s\n", logPath)
573+
return nil
574+
}
575+
576+
func runEndpointElasticDown(ctx context.Context) error {
577+
if runtime.GOOS != "darwin" {
578+
return fmt.Errorf("beacon endpoint elastic down is currently macOS-only")
579+
}
580+
packDir := endpointOpts.elasticPackDir
581+
if packDir == "" {
582+
packDir = elastic.DefaultOutputDir
583+
}
584+
if _, err := os.Stat(filepath.Join(packDir, "docker-compose.yml")); os.IsNotExist(err) {
585+
fmt.Printf("No Elasticsearch stack found for %s\n", packDir)
586+
return nil
587+
} else if err != nil {
588+
return err
589+
}
590+
logPath, err := filepath.Abs(loadOrDefaultConfig().LogPath)
591+
if err != nil {
592+
return err
593+
}
594+
env := append(os.Environ(), "BEACON_LOG_DIR="+filepath.Dir(logPath))
595+
if err := runDockerCompose(ctx, packDir, env, "down", "--remove-orphans"); err != nil {
596+
return err
597+
}
598+
fmt.Printf("Elasticsearch stack stopped for %s\n", packDir)
599+
return nil
600+
}
601+
602+
func ensureElasticPack(packDir, logPath string) error {
603+
if _, err := os.Stat(filepath.Join(packDir, "docker-compose.yml")); err == nil {
604+
return nil
605+
} else if !os.IsNotExist(err) {
606+
return err
607+
}
608+
if err := elastic.InstallPack(packDir, logPath); err != nil {
609+
return err
610+
}
611+
fmt.Printf("Elasticsearch content pack written to %s\n", packDir)
612+
return nil
613+
}
614+
615+
func ensureLogFile(path string) error {
616+
if path == "" {
617+
return nil
618+
}
619+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
620+
return err
621+
}
622+
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND, 0644)
623+
if err != nil {
624+
return err
625+
}
626+
return file.Close()
627+
}
628+
629+
func runDockerCompose(ctx context.Context, dir string, env []string, args ...string) error {
630+
if _, err := os.Stat(filepath.Join(dir, "docker-compose.yml")); err != nil {
631+
return fmt.Errorf("docker-compose.yml not found in %s: %w", dir, err)
632+
}
633+
fullArgs := append([]string{"compose"}, args...)
634+
cmd := exec.CommandContext(ctx, "docker", fullArgs...)
635+
cmd.Dir = dir
636+
cmd.Env = env
637+
cmd.Stdout = os.Stdout
638+
cmd.Stderr = os.Stderr
639+
return cmd.Run()
640+
}
641+
642+
func envDefault(name, fallback string) string {
643+
value := strings.TrimSpace(os.Getenv(name))
644+
if value == "" {
645+
return fallback
646+
}
647+
return value
648+
}
649+
475650
func runEndpointDashboard(cmd *cobra.Command, args []string) error {
476651
cfg := loadOrDefaultConfig()
477652
userMode := endpointUserMode()

cli/beacon/cmd/endpoint_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package cmd
22

33
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
47
"testing"
58

69
"github.com/spf13/cobra"
@@ -50,6 +53,80 @@ func TestEndpointCoworkSetupCommandRegistered(t *testing.T) {
5053
}
5154
}
5255

56+
func TestEndpointElasticCommandsRegistered(t *testing.T) {
57+
for _, path := range [][]string{
58+
{"elastic", "print-config"},
59+
{"elastic", "install-pack"},
60+
{"elastic", "up"},
61+
{"elastic", "down"},
62+
} {
63+
cmd, _, err := endpointCmd.Find(path)
64+
if err != nil {
65+
t.Fatalf("Find %v returned error: %v", path, err)
66+
}
67+
if cmd == nil || cmd.Use != path[len(path)-1] {
68+
t.Fatalf("elastic command %v not registered: %#v", path, cmd)
69+
}
70+
}
71+
if endpointElasticInstallPackCmd.Flags().Lookup("output") == nil {
72+
t.Fatal("elastic install-pack command missing --output flag")
73+
}
74+
if endpointElasticUpCmd.Flags().Lookup("pack-dir") == nil {
75+
t.Fatal("elastic up command missing --pack-dir flag")
76+
}
77+
if endpointElasticDownCmd.Flags().Lookup("pack-dir") == nil {
78+
t.Fatal("elastic down command missing --pack-dir flag")
79+
}
80+
for _, name := range []string{"user", "system", "log-path"} {
81+
if endpointElasticDownCmd.Flags().Lookup(name) == nil {
82+
t.Fatalf("elastic down command missing --%s flag", name)
83+
}
84+
}
85+
}
86+
87+
func TestEnsureElasticPackDoesNotOverwriteExistingPack(t *testing.T) {
88+
dir := t.TempDir()
89+
composePath := filepath.Join(dir, "docker-compose.yml")
90+
if err := os.WriteFile(composePath, []byte("custom compose"), 0644); err != nil {
91+
t.Fatal(err)
92+
}
93+
if err := ensureElasticPack(dir, "/tmp/beacon/runtime.jsonl"); err != nil {
94+
t.Fatalf("ensureElasticPack returned error: %v", err)
95+
}
96+
got, err := os.ReadFile(composePath)
97+
if err != nil {
98+
t.Fatal(err)
99+
}
100+
if string(got) != "custom compose" {
101+
t.Fatalf("ensureElasticPack overwrote existing pack: %s", got)
102+
}
103+
}
104+
105+
func TestRunEndpointElasticDownIgnoresMissingPack(t *testing.T) {
106+
if runtime.GOOS != "darwin" {
107+
t.Skip("elastic down is currently macOS-only")
108+
}
109+
oldPackDir := endpointOpts.elasticPackDir
110+
endpointOpts.elasticPackDir = filepath.Join(t.TempDir(), "missing-pack")
111+
t.Cleanup(func() {
112+
endpointOpts.elasticPackDir = oldPackDir
113+
})
114+
if err := runEndpointElasticDown(t.Context()); err != nil {
115+
t.Fatalf("runEndpointElasticDown returned error for missing pack: %v", err)
116+
}
117+
}
118+
119+
func TestEnvDefault(t *testing.T) {
120+
t.Setenv("BEACON_TEST_VALUE", "")
121+
if got := envDefault("BEACON_TEST_VALUE", "fallback"); got != "fallback" {
122+
t.Fatalf("envDefault empty = %q", got)
123+
}
124+
t.Setenv("BEACON_TEST_VALUE", " 12345 ")
125+
if got := envDefault("BEACON_TEST_VALUE", "fallback"); got != "12345" {
126+
t.Fatalf("envDefault value = %q", got)
127+
}
128+
}
129+
53130
func TestEndpointCoworkValidateSinceFlagRegistered(t *testing.T) {
54131
cmd, _, err := endpointCmd.Find([]string{"integrations", "claude-cowork", "validate"})
55132
if err != nil {

0 commit comments

Comments
 (0)