Skip to content

Commit

Permalink
Heartbeat: add support for pushed monitor source (#31428)
Browse files Browse the repository at this point in the history
* Heartbeat: add support for pushed monitors

* link to global synthetics pkg

* add tests and link global path

* fix lint issues

* fix tests

* add changelog

* use install instead of link

* handle zipslip vul
  • Loading branch information
vigneshshanmugam authored May 16, 2022
1 parent ad92b20 commit 2696fb2
Show file tree
Hide file tree
Showing 5 changed files with 283 additions and 21 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.next.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ https://github.com/elastic/beats/compare/v7.0.0-alpha2...main[Check the HEAD dif

*Heartbeat*

- Add support for `pushed` browser monitor source from the synthetics agent. {pull}31428[31428]


*Metricbeat*

Expand Down
131 changes: 131 additions & 0 deletions x-pack/heartbeat/monitors/browser/source/pushed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// 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 source

import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"

"github.com/elastic/elastic-agent-libs/mapstr"
)

type PushedSource struct {
Content string `config:"content" json:"content"`
TargetDirectory string
}

var ErrNoContent = fmt.Errorf("no 'content' value specified for pushed monitor source")

func (p *PushedSource) Validate() error {
if !regexp.MustCompile(`\S`).MatchString(p.Content) {
return ErrNoContent
}

return nil
}

func (p *PushedSource) Fetch() error {
decodedBytes, err := base64.StdEncoding.DecodeString(p.Content)
if err != nil {
return err
}

tf, err := ioutil.TempFile("/tmp", "elastic-synthetics-zip-")
if err != nil {
return fmt.Errorf("could not create tmpfile for pushed monitor source: %w", err)
}
defer os.Remove(tf.Name())

// copy the encoded contents in to a temp file for unzipping later
_, err = io.Copy(tf, bytes.NewReader(decodedBytes))
if err != nil {
return err
}

p.TargetDirectory, err = ioutil.TempDir("/tmp", "elastic-synthetics-unzip-")
if err != nil {
return fmt.Errorf("could not make temp dir for unzipping pushed source: %w", err)
}

err = unzip(tf, p.Workdir(), "")
if err != nil {
p.Close()
return err
}

// Offline is not required for pushed resources as we are only linking
// to the globally installed agent, but useful for testing purposes
if !Offline() {
// set up npm project and ensure synthetics is installed
err = setupProjectDir(p.Workdir())
if err != nil {
return fmt.Errorf("setting up project dir failed: %w", err)
}
}

return nil
}

type PackageJSON struct {
Name string `json:"name"`
Private bool `json:"private"`
Dependencies mapstr.M `json:"dependencies"`
}

// setupProjectDir sets ups the required package.json file and
// links the synthetics dependency to the globally installed one that is
// baked in to the Heartbeat image to maintain compatibility and
// allows us to control the synthetics agent version
func setupProjectDir(workdir string) error {
fname, err := exec.LookPath("elastic-synthetics")
if err == nil {
fname, err = filepath.Abs(fname)
}
if err != nil {
return fmt.Errorf("cannot resolve global synthetics library: %w", err)
}

globalPath := strings.Replace(fname, "bin/elastic-synthetics", "lib/node_modules/@elastic/synthetics", 1)
symlinkPath := fmt.Sprintf("file:%s", globalPath)
pkgJson := PackageJSON{
Name: "pushed-journey",
Private: true,
Dependencies: mapstr.M{
"@elastic/synthetics": symlinkPath,
},
}
pkgJsonContent, err := json.MarshalIndent(pkgJson, "", " ")
if err != nil {
return err
}
//nolint:gosec //for permission
err = ioutil.WriteFile(filepath.Join(workdir, "package.json"), pkgJsonContent, 0755)
if err != nil {
return err
}

// setup the project linking to the global synthetics library
return runSimpleCommand(exec.Command("npm", "install"), workdir)
}

func (p *PushedSource) Workdir() string {
return p.TargetDirectory
}

func (p *PushedSource) Close() error {
if p.TargetDirectory != "" {
return os.RemoveAll(p.TargetDirectory)
}
return nil
}
98 changes: 98 additions & 0 deletions x-pack/heartbeat/monitors/browser/source/pushed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// 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 source

import (
"os"
"path"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v2"

"github.com/elastic/elastic-agent-libs/config"
"github.com/elastic/elastic-agent-libs/mapstr"
)

func setUpTests() func() {
GoOffline()
return func() {
GoOnline()
}
}

func TestPushedSource(t *testing.T) {
teardown := setUpTests()
defer teardown()

type testCase struct {
name string
cfg mapstr.M
wantErr bool
}
testCases := []testCase{
{
"decode pushed content",
mapstr.M{
"content": "UEsDBBQACAAIAJ27qVQAAAAAAAAAAAAAAAAiAAAAZXhhbXBsZXMvdG9kb3MvYWR2YW5jZWQuam91cm5leS50c5VRPW/CMBDd+RWnLA0Sigt0KqJqpbZTN+iEGKzkIC6JbfkuiBTx3+uEEAGlgi7Rnf38viIESCLkR/FJ6Eis1VIjpanATBKrWFCpOUU/kcCNzG2GJNgkhoRM1lLHmERfpnAay4ipo3JrHMMWmjPYwcKZHILn33zBqIV3ADIjkxdrJ4y251eZJFNJq3b1Hh1XJx+KeKK+8XATpxiv3o07RidI7Ex5OOocTEQixcz6mF66MRgGXkmxMhqkTiA2VcJ6NQsgpZcZAnueoAfhFqxcYs9/ncwJdl0YP9XeY6OJgb3qFDcMYwhejb5jsAUDyYxBaSi9HmCJlfZJ2vCYNCpc1h2d5m8AB/r99cU+GmS/hpwXc4nmrKh/K917yK57VqZe1lU6zM26WvIiY2WbHunWIiusb3IWVBP0/bP9NGinYTC/qcqWLloY9ybjNAy5VbzYdP1sdz3+8FqJleqsP7/ONPjjp++TPgS3eaks/wBQSwcIVYIEHGwBAADRAwAAUEsDBBQACAAIAJ27qVQAAAAAAAAAAAAAAAAZAAAAZXhhbXBsZXMvdG9kb3MvaGVscGVycy50c5VUTYvbMBC9768YRGAVyKb0uktCu9CeektvpRCtM4nFKpKQxt2kwf+9I9lJ5cRb6MWW5+u9eTOW3nsXCE4QCf0M8OCxImhhG9wexCc0KpKuPsSjpRr5FMXTXeVsJDBObT57v+I8WID0aoczaIKZwmIJpzvIFaUwqrFVDcp7MQPFdSqQlxAA9aY0QUqe7xw5mQo8saflZ3uGUpvNdxVfh1DEliHWmuOyGSan9GrXY4hdSW19Q1yswJ9Ika1zi28P5DZOZCZnjp2Pjh5lhr71+YAxSvHFEgZx20UqGVdoWGAXGFo0Zp5sD0YnOXX+uMi71TY3nTh2PYy0HZCaYMsm0umrC2cYuWYpStwWlksgPNBC9CKJ9UDqGDFQAv7GrFb6N/aqD0hEtl9pX9VYvQLViroR5KZqFXmlVEXmyDNJWS0wkT1aiqPD6fZPynIsEznoYDqdG7Q7qqcs2DPKzOVG7EyHhSj25n0Zyw62PJvcwH2vzz1PN3czSrifwHlaZfUbThuMFNzxPyj1GVeE/rHWRr2guaz1e6wu0foSmhPTL3DwiuqFshVDu/D4aPSPjz/FIK1n9dwQOfu3gk7pL9k4jK+M5lk0LBRy9CB7nn2yD+cStfuFQQ5+riK9kJQ3JV9cbCmuh1n6HF3h5LleimS7GkoynWVL5+KWS6h/AFBLBwgvDHpj+wEAAC8FAABQSwECLQMUAAgACACdu6lUVYIEHGwBAADRAwAAIgAAAAAAAAAAACAApIEAAAAAZXhhbXBsZXMvdG9kb3MvYWR2YW5jZWQuam91cm5leS50c1BLAQItAxQACAAIAJ27qVQvDHpj+wEAAC8FAAAZAAAAAAAAAAAAIACkgbwBAABleGFtcGxlcy90b2Rvcy9oZWxwZXJzLnRzUEsFBgAAAAACAAIAlwAAAP4DAAAAAA==",
},
false,
},
{
"bad encoded content",
mapstr.M{
"content": "12312edasd",
},
true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
psrc, err := dummyPSource(tc.cfg)
if tc.wantErr {
err = psrc.Fetch()
require.Error(t, err)
return
}
require.NoError(t, err)
fetchAndValidate(t, psrc)
})
}
}

func validateFileContents(t *testing.T, dir string) {
expected := []string{
"examples/todos/helpers.ts",
"examples/todos/advanced.journey.ts",
}
for _, file := range expected {
_, err := os.Stat(path.Join(dir, file))
assert.NoError(t, err)
}
}

func fetchAndValidate(t *testing.T, psrc *PushedSource) {
err := psrc.Fetch()
require.NoError(t, err)

validateFileContents(t, psrc.Workdir())
// check if the working directory is deleted
require.NoError(t, psrc.Close())
_, err = os.Stat(psrc.TargetDirectory)
require.True(t, os.IsNotExist(err), "TargetDirectory %s should have been deleted", psrc.TargetDirectory)
}

func dummyPSource(conf map[string]interface{}) (*PushedSource, error) {
psrc := &PushedSource{}
y, _ := yaml.Marshal(conf)
c, err := config.NewConfigWithYAML(y, string(y))
if err != nil {
return nil, err
}
err = c.Unpack(psrc)
return psrc, err
}
3 changes: 3 additions & 0 deletions x-pack/heartbeat/monitors/browser/source/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type Source struct {
Local *LocalSource `config:"local"`
Inline *InlineSource `config:"inline" json:"inline"`
ZipUrl *ZipURLSource `config:"zip_url" json:"zip_url"`
Pushed *PushedSource `config:"pushed" json:"pushed"`
ActiveMemo ISource // cache for selected source
}

Expand All @@ -26,6 +27,8 @@ func (s *Source) Active() ISource {
s.ActiveMemo = s.Inline
} else if s.ZipUrl != nil {
s.ActiveMemo = s.ZipUrl
} else if s.Pushed != nil {
s.ActiveMemo = s.Pushed
}

return s.ActiveMemo
Expand Down
70 changes: 49 additions & 21 deletions x-pack/heartbeat/monitors/browser/source/zipurl.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func (z *ZipURLSource) Fetch() error {
if !Offline() {
err = setupOnlineDir(z.TargetDirectory)
if err != nil {
os.RemoveAll(z.TargetDirectory)
z.Close()
return fmt.Errorf("failed to install dependencies at: '%s' %w", z.TargetDirectory, err)
}
}
Expand All @@ -104,15 +104,11 @@ func (z *ZipURLSource) Fetch() error {
}

func unzip(tf *os.File, targetDir string, folder string) error {
stat, err := tf.Stat()
rdr, err := zip.OpenReader(tf.Name())
if err != nil {
return err
}

rdr, err := zip.NewReader(tf, stat.Size())
if err != nil {
return fmt.Errorf("could not read file %s as zip: %w", tf.Name(), err)
}
defer rdr.Close()

for _, f := range rdr.File {
err = unzipFile(targetDir, folder, f)
Expand All @@ -127,24 +123,45 @@ func unzip(tf *os.File, targetDir string, folder string) error {
return nil
}

func sanitizeFilePath(filePath string, workdir string) (string, error) {
destPath := filepath.Join(workdir, filePath)
if !strings.HasPrefix(destPath, filepath.Clean(workdir)+string(os.PathSeparator)) {
return filePath, fmt.Errorf("failed to extract illegal file path: %s", filePath)
}
return destPath, nil
}

// unzip file takes a given directory and a zipped file and extracts
// all the contents of the file based on the provided folder path,
// if the folder path is empty, it extracts the contents based on file
// tree structure
func unzipFile(workdir string, folder string, f *zip.File) error {
folderPaths := strings.Split(folder, string(filepath.Separator))
var folderDepth = 1
for _, path := range folderPaths {
if path != "" {
folderDepth++
var destPath string
var err error
if folder != "" {
folderPaths := strings.Split(folder, string(filepath.Separator))
var folderDepth = 1
for _, path := range folderPaths {
if path != "" {
folderDepth++
}
}
}
splitZipFileName := strings.Split(f.Name, string(filepath.Separator))
root := splitZipFileName[0]
splitZipFileName := strings.Split(f.Name, string(filepath.Separator))
root := splitZipFileName[0]

prefix := filepath.Join(root, folder)
if !strings.HasPrefix(f.Name, prefix) {
return nil
}
prefix := filepath.Join(root, folder)
if !strings.HasPrefix(f.Name, prefix) {
return nil
}

sansFolder := splitZipFileName[folderDepth:]
destPath := filepath.Join(workdir, filepath.Join(sansFolder...))
sansFolder := splitZipFileName[folderDepth:]
destPath = filepath.Join(workdir, filepath.Join(sansFolder...))
} else {
destPath, err = sanitizeFilePath(f.Name, workdir)
if err != nil {
return err
}
}

// Never unpack node modules
if strings.HasPrefix(destPath, "node_modules/") {
Expand All @@ -159,6 +176,17 @@ func unzipFile(workdir string, folder string, f *zip.File) error {
return nil
}

// In the case of pushed monitors, the destPath would be the direct
// file path instead of directory, so we create the directory
// if its not set up properly
destDir := filepath.Dir(destPath)
if _, err := os.Stat(destDir); os.IsNotExist(err) {
err = os.MkdirAll(destDir, 0700) // Create your file
if err != nil {
return fmt.Errorf("could not make dest zip dir '%s': %w", destDir, err)
}
}

dest, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("could not create dest file for zip '%s': %w", destPath, err)
Expand Down

0 comments on commit 2696fb2

Please sign in to comment.