InfraHamBurglar: a CyberSecurity News Bot built with AWS Lambda, GoLang, and Twitter API v2

InfraHamBurglar: a CyberSecurity News Bot built with AWS Lambda, GoLang, and Twitter API v2
InfraHamBurglar a Twitter Bot for sharing CyberSecurity News

Today I am introducing the InfraHamBurglar Twitter Bot, which is reporting on the latest "Robble, Robble" worldwide.

As someone who works in cybersecurity, I thought it would be funny to have a Twitter bot named the "InfraHamBurglar" as a pun on an Infrastructure Burglar that is going Ham. Where Infrastructure loosely means Servers and Databases that exist in the cloud.

For those unfamiliar, the HamBurglar is a McDonald's character from the past that is known for stealing hamburgers. The bot's job is to Tweet out the latest CyberAttacks and DataBreaches with the phrase "Robble, Robble, Good Gobble." and a link to tweet.

Let's get to it.

1. Sign - Up for a Developer Account for Twitter Developer Portal

Go here and provide the necessary information to create a developer account.

Next, create a project and name it whatever you want. In my case, it is InfraHamBurglar.  Then, once the project is completed, go ahead and generate the API KEY and API SECRET

Twitter API Key and API Secret

Then apply for an elevated permissions account so your bot can *tweet otherwise, your bot will only be able to READ tweets.

The time varies on approval, and you'll probably have to go back and forth via e-mail with Twitter Support to get approved. It took me about 3 days for me.

2. Code with gotwi api v2 pkg

Thankfully the wonderful GoTwi package exists and has efficiently wrapped all the URL endpoints for Twitter API v2.

The Package author even provided working examples that we'll use to cobble together the bot.

2.1 Create main.go file

Source

package main

import (
	"fmt"
	"net/http"
	"os"
	"time"

	"github.com/michimani/gotwi"
)

func main() {
	args := os.Args

	if len(args) < 2 {
		fmt.Println("The 1st parameter for command is required. (create|stream)")
		os.Exit(1)
	}

	command := args[1]

	switch command {
	case "list":
		// list search stream rules
		listSearchStreamRules()
	case "delete":
		// delete a specified rule
		if len(args) < 3 {
			fmt.Println("The 2nd parameter for rule ID to delete is required.")
			os.Exit(1)
		}

		ruleID := args[2]
		deleteSearchStreamRules(ruleID)
	case "create":
		// create a search stream rule
		if len(args) < 3 {
			fmt.Println("The 2nd parameter for keyword of search stream rule is required.")
			os.Exit(1)
		}

		keyword := args[2]
		createSearchStreamRules(keyword)
	case "stream":
		// exec filtered stream API
		execSearchStream()
	default:
		fmt.Println("Undefined command. Command should be 'create' or 'stream'.")
		os.Exit(1)
	}
}

// newGotwiClientWithTimeout creates a new gotwi.Client
// that has custom http.Client with arbitrary timeout.
func newGotwiClientWithTimeout(timeout int) (*gotwi.Client, error) {
	in := &gotwi.NewClientInput{
		AuthenticationMethod: gotwi.AuthenMethodOAuth2BearerToken,
		HTTPClient: &http.Client{
			Timeout: time.Duration(timeout) * time.Second,
		},
	}

	return gotwi.NewClient(in)
}

and also create

2.1 filterered_stream.go

Source

package main

import (
	"context"
	"fmt"

	"github.com/michimani/gotwi"
	"github.com/michimani/gotwi/tweet/filteredstream"
	"github.com/michimani/gotwi/tweet/filteredstream/types"
)

// createSearchStreamRules lists search stream rules.
func listSearchStreamRules() {
	c, err := newGotwiClientWithTimeout(30)
	if err != nil {
		fmt.Println(err)
		return
	}

	p := &types.ListRulesInput{}
	res, err := filteredstream.ListRules(context.Background(), c, p)
	if err != nil {
		fmt.Println(err.Error())
		return
	}

	for _, r := range res.Data {
		fmt.Printf("ID: %s, Value: %s, Tag: %s\n", gotwi.StringValue(r.ID), gotwi.StringValue(r.Value), gotwi.StringValue(r.Tag))
	}
}

func deleteSearchStreamRules(ruleID string) {
	c, err := newGotwiClientWithTimeout(30)
	if err != nil {
		fmt.Println(err)
		return
	}

	p := &types.DeleteRulesInput{
		Delete: &types.DeletingRules{
			IDs: []string{
				ruleID,
			},
		},
	}

	res, err := filteredstream.DeleteRules(context.TODO(), c, p)
	if err != nil {
		fmt.Println(err.Error())
		return
	}

	for _, r := range res.Data {
		fmt.Printf("ID: %s, Value: %s, Tag: %s\n", gotwi.StringValue(r.ID), gotwi.StringValue(r.Value), gotwi.StringValue(r.Tag))
	}
}

// createSearchStreamRules creates a search stream rule.
func createSearchStreamRules(keyword string) {
	c, err := newGotwiClientWithTimeout(30)
	if err != nil {
		fmt.Println(err)
		return
	}

	p := &types.CreateRulesInput{
		Add: []types.AddingRule{
			{Value: gotwi.String(keyword), Tag: gotwi.String(keyword)},
		},
	}

	res, err := filteredstream.CreateRules(context.TODO(), c, p)
	if err != nil {
		fmt.Println(err.Error())
		return
	}

	for _, r := range res.Data {
		fmt.Printf("ID: %s, Value: %s, Tag: %s\n", gotwi.StringValue(r.ID), gotwi.StringValue(r.Value), gotwi.StringValue(r.Tag))
	}
}

// execSearchStream call GET /2/tweets/search/stream API
// and outputs up to 10 results.
func execSearchStream() {
	c, err := newGotwiClientWithTimeout(120)
	if err != nil {
		fmt.Println(err)
		return
	}

	p := &types.SearchStreamInput{}
	s, err := filteredstream.SearchStream(context.Background(), c, p)
	if err != nil {
		fmt.Println(err)
		return
	}

	cnt := 0
	for s.Receive() {
		t, err := s.Read()
		if err != nil {
			fmt.Println(err)
		} else {
			if t != nil {
				cnt++
				fmt.Println(gotwi.StringValue(t.Data.ID), gotwi.StringValue(t.Data.Text))
			}
		}

		if cnt > 10 {
			s.Stop()
			break
		}
	}
}

2.2 Create .env file

For the code to work it'll need to have local variables available if you're running from your personal laptop for testing purposes.

GOTWI_API_KEY=<YOUR_API_KEY>
GOTWI_API_KEY_SECRET=<YOUR_API_KEY_SECRET>
GOTWI_ACCESS_TOKEN=<YOUR_ACCESS_TOKEN>
GOTWI_ACCESS_TOKEN_SECRET=<YOUR_ACCESS_TOKEN_SECRET>

N.B. Export the vars with this line to your local terminal if you want to test locally as you develop.

> for line in $(cat .env); do export $line; done

2.3 Run code to create filtered stream term

go run main.go create <name-of-your-term>

This will create a term that the Twitter API v2 filtered stream will filter out when searching through tweets. You may also add other terms as well and the stream will also capture those tweets as well.

Now, that the filter term(s) have been created let's code up the bot.

2.4 Altering the code for the InfraHamburglarBot

Source

I am choosing to use AWS Lambda because it is the cheapest option that I'm familiar with. A quick calculation based on the AWS Lambda pricing docs shows that this project will only cost me under $2 USD each month.

cost of lambda every 15 mins => 120000 ms X 0.0000000021 cents/ms x 96 executions per day

Which is much cheaper than running a small cloud server in terms of cost, maintenance, and security. A lambda is only up for barely 2 minutes versus a server that runs 1440 minutes a day (lots of time for a curious InfraHamBurglar to try and exploit).

All that the reader should need to change is the USER and what text they want their bot to tweet when retweeting a tweet;  the line that reads
p := "Robble, Robble, Good Gobble " + "https://twitter.com/" + gotwi.StringValue(t.Data.AuthorID) + "/status/" + gotwi.StringValue(t.Data.ID)

Also, delete the filtered_streams.go file as it isn't needed and will add to the Zip file size if included.

However, if the reader wishes to change what the bot does retweet, direct message, reply, and so on then, this code should serve as a decent starting point.

package main

import (
	"context"
	"fmt"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/michimani/gotwi"
	"github.com/michimani/gotwi/fields"
	"github.com/michimani/gotwi/tweet/filteredstream"
	"github.com/michimani/gotwi/tweet/filteredstream/types"
	"github.com/michimani/gotwi/tweet/managetweet"
	twt "github.com/michimani/gotwi/tweet/managetweet/types"
	"net/http"
	"os"
	"strings"
	"time"
)

const (
	OAuthTokenEnvKeyName       = "GOTWI_ACCESS_TOKEN"
	OAuthTokenSecretEnvKeyName = "GOTWI_ACCESS_TOKEN_SECRET"
	USER                       = "1510513502628331522"
)

// SimpleTweet posts a tweet with only text, and return posted tweet ID.
func SimpleTweet(c *gotwi.Client, text string) (string, error) {
	p := &twt.CreateInput{
		Text: gotwi.String(text),
	}

	res, err := managetweet.Create(context.Background(), c, p)
	if err != nil {
		return "", err
	}

	return gotwi.StringValue(res.Data.ID), nil
}

func newOAuth1Client() (*gotwi.Client, error) {
	in := &gotwi.NewClientInput{
		AuthenticationMethod: gotwi.AuthenMethodOAuth1UserContext,
		OAuthToken:           os.Getenv(OAuthTokenEnvKeyName),
		OAuthTokenSecret:     os.Getenv(OAuthTokenSecretEnvKeyName),
	}
	return gotwi.NewClient(in)
}

// execSearchStream call GET /2/tweets/search/stream API
// and outputs up to 10 results.
func execSearchStream() {

	c, err := newGotwiClientWithTimeout(120)
	if err != nil {
		fmt.Println(err)
		return
	}

	p := &types.SearchStreamInput{
		TweetFields: fields.TweetFieldList{
			fields.TweetFieldAuthorID,
		},
	}
	s, err := filteredstream.SearchStream(context.Background(), c, p)
	if err != nil {
		fmt.Println(err)
		return
	}
	cnt := 0
	for s.Receive() {
		t, err := s.Read()
		if err != nil {
			fmt.Printf("ERR: ", err)
		} else {
			if t != nil {
				if gotwi.StringValue(t.Data.AuthorID) != USER {
					if !strings.Contains(gotwi.StringValue(t.Data.Text), "RT") {
						cnt++
						oauth1Client, err := newOAuth1Client()
						if err != nil {
							panic(err)
						}
						p := "Robble, Robble, Good Gobble " + "https://twitter.com/" + gotwi.StringValue(t.Data.AuthorID) + "/status/" + gotwi.StringValue(t.Data.ID)
						tweetID, err := SimpleTweet(oauth1Client, p)
						if err != nil {
							panic(err)
						}
						fmt.Println("Posted tweet ID is ", tweetID)
					}

				}
			}
			if cnt > 10 {
				s.Stop()
				break
			}
		}

	}
}

func newGotwiClientWithTimeout(timeout int) (*gotwi.Client, error) {
	in := &gotwi.NewClientInput{
		AuthenticationMethod: gotwi.AuthenMethodOAuth2BearerToken,
		HTTPClient: &http.Client{
			Timeout: time.Duration(timeout) * time.Second,
		},
	}
	return gotwi.NewClient(in)
}

func main() {
	lambda.Start(execSearchStream)
}

3. Deploying to AWS Lambda

Now, that the code is ready and unnecessary files have been removed let's get to the infrastructure side of this project.

3.1 Zipping

As I previously shared under section II.c Build and push the package to S3 bucket AWS requires the lambda to be of a certain form when created, and that means build as well so use this shell script to build the *.zip for your bot.

> GOOS=linux  GOARCH=amd64 go build -o your-awesome-bot main.go
> zip your-awesome-bot.zip your-awesome-bot

Now, that the zip has been made head over to your AWS console and go to Lambda in the AWS Console.

3.2 Creating lambda

Select Create new lambda
Name it
Once it has been created go to  Code Tab >Runtime Settings > Handler > and change the name from hello to <your-awesome-bot>

3.3 Set Up Env Variables

Next, go to Configuration Tab > Environment Variables > Add Variables.

Add all the variables from your .env file.

In the end, there should be four present.

3.4 General Configuration

Now go to Configuration Tab > General Configuration > Edit to change the default values.

Change memory to the min of 128 MB

Change the default time out to 3 minutes

Leave default Ephemeral storage size (can't change it)

This will save you money in the long run.

3.3 Setting up EventBridge Rule

Now, Click Triggers on the AWS Lambda and select EventBridge.

Select Create New rule and add in the info you feel is important.

Lastly, input your cron() expression which will trigger the lambda every so often.

I personally opted for running the bot every 15 mins.

Conclusion

Now, you have a general framework for constructing Twitter bots in GoLang. Please consider giving the InfraHamburglarBot and me a follow if you found the article helpful.