diff --git a/.gitignore b/.gitignore index d687a54..9408a42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .vscode/ Jecrets +*.swp +*.log.json +output*/ diff --git a/Makefile b/Makefile index f52d39e..b4d40b7 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,6 @@ testlite: go get github.com/mattn/goveralls go get github.com/sozorogami/gover - go test -v "github.com/GESkunkworks/gossamer/acfmgr" -covermode=count -coverprofile=acfmgr.coverprofile go test -v "github.com/GESkunkworks/gossamer/gossamer" -covermode=count -coverprofile=gossamer.coverprofile gover @@ -61,4 +60,4 @@ clean: rm -rf $(build_dir) rm -rf $(build_dir_linux) rm -rf $(build_dir_mac) - rm -rf $(build_dir_windows) \ No newline at end of file + rm -rf $(build_dir_windows) diff --git a/acfmgr/acfmgr.go b/acfmgr/acfmgr.go deleted file mode 100644 index 04c4e17..0000000 --- a/acfmgr/acfmgr.go +++ /dev/null @@ -1,251 +0,0 @@ -// Package acfmgr is a package to manage entries in an AWS credentials file -// -// Sample AWS creds file format: -// [default] -// output = json -// region = us-east-1 -// aws_access_key_id = QOWIASOVNALKNVCIE -// aws_secret_access_key = zgylMqe64havoaoinweofnviUHqQKYHMGzFMA8CI -// aws_session_token = FQoDYXdzEGYaDNYfEnCsHW/8rG3zpiKwAfS8T... -// -// [dev-default] -// output = json -// region = us-west-1 -// aws_access_key_id = QOWIAADFEGKNVCIE -// aws_secret_access_key = zgylMqaoivnawoeenweofnviUHqQKYHMGzFMA8CI -// aws_session_token = FQoDYXdzEGYaDNYfEnCsanv;oaiwe\iKwAfS8T... -// -// Adding and removing entries manually is a pain so this package was created -// to assist in programattically adding them once you have sessions built -// from the Golang AWS SDK. -// -// Calling AssertEntries will delete all entries of that name and only rewrite -// the given entry with the given contents. -// -// Calling DeleteEntries will delete all entries of that name. -// -// Sample -// -// c, err := acfmgr.NewCredFileSession("~/.aws/credentials") -// check(err) -// c.NewEntry("[dev-account-1]", []string{"output = json", "region = us-east-1", "...", ""}) -// c.NewEntry("[dev-account-2]", []string{"output = json", "region = us-west-1", "...", ""}) -// err = c.AssertEntries() -// Yields: -// [dev-account-1] -// output = json -// region = us-east-1 -// ... -// -// [dev-account-2] -// output = json -// region = us-west-1 -// ... -// -// While: -// c, err := acfmgr.NewCredFileSession("~/.aws/credentials") -// check(err) -// c.NewEntry("[dev-account-2]", []string{"output = json", "region = us-west-1", "...", ""}) -// err = c.DeleteEntries() -// Yields: -// [dev-account-2] -// output = json -// region = us-west-1 -// ... -// -package acfmgr - -import ( - "bufio" - "bytes" - "fmt" - "io/ioutil" - "os" - "regexp" -) - -// NewCredFileSession creates a new interactive credentials file -// session. Needs target filename and returns CredFile obj and err. -func NewCredFileSession(filename string) (*CredFile, error) { - cf := CredFile{filename: filename, - currBuff: new(bytes.Buffer), - reSep: regexp.MustCompile(`\[.*\]`), - } - err := cf.loadFile() - if err != nil { - return &cf, err - } - return &cf, err -} - -// CredFile should be built with the exported -// NewCredFileSession function. -type CredFile struct { - filename string - ents []*credEntry - currBuff *bytes.Buffer - reSep *regexp.Regexp // regex cred anchor separator e.g. "[\w*]" -} - -type credEntry struct { - name string - contents []string -} - -// NewEntry adds a new credentials entry to the queue -// to be written or deleted with the AssertEntries or -// DeleteEntries method. -func (c *CredFile) NewEntry(entryName string, entryContents []string) { - e := credEntry{name: entryName, contents: entryContents} - c.ents = append(c.ents, &e) -} - -// AssertEntries loops through all of the credEntry objs -// attached to CredFile obj and makes sure there is an -// occurrence with the credEntry.name and contents. -// Existing entries of the same name with different -// contents will be clobbered. -func (c *CredFile) AssertEntries() (err error) { - for _, e := range c.ents { - err = c.modifyEntry(true, e) - if err != nil { - return err - } - } - return err -} - -// DeleteEntries loops through all of the credEntry -// objs attached to CredFile obj and makes sure entries -// with the same credEntry.name are removed. Will remove -// ALL entries with the same name. -func (c *CredFile) DeleteEntries() (err error) { - for _, e := range c.ents { - err = c.modifyEntry(false, e) - if err != nil { - return err - } - } - return err -} - -func (e *credEntry) appendToList(lister []string) []string { - lister = append(lister, e.name) - for _, line := range e.contents { - lister = append(lister, line) - } - return lister -} - -func (c *CredFile) loadFile() error { - if !c.fileExists() { - _, err := c.createFile() - if err != nil { - panic(err) - } - } - f, err := os.OpenFile(c.filename, os.O_RDONLY, os.ModeAppend) - if err != nil { - panic(err) - } - scanner := bufio.NewScanner(f) - for scanner.Scan() { - c.currBuff.WriteString(scanner.Text() + "\n") - } - return err -} - -func (c *CredFile) writeBufferToFile() error { - err := ioutil.WriteFile(c.filename, c.currBuff.Bytes(), 0644) - return err -} - -// indexOf find the index of a value in an []int -func indexOf(s []int, e int) (index int) { - for index, a := range s { - if a == e { - return index - } - } - return -1 -} - -func (c *CredFile) removeEntry(data []string, anchors []int, entry *credEntry) []string { - ignoring := false - ignoreUntil := 0 - var newLines []string - for i, line := range data { - if line == entry.name { - currIndex := indexOf(anchors, i) - if (currIndex + 1) >= len(anchors) { - // this means it's at EOF - ignoreUntil = len(data) - } else { - ignoreUntil = anchors[currIndex+1] - } - - ignoring = true - } - if !(ignoring && i < ignoreUntil) { - newLines = append(newLines, line) - } - } - return newLines -} - -// EnsureEntryExists makes sure that the attached Ent -// entry exists. -func (c *CredFile) modifyEntry(replace bool, entry *credEntry) (err error) { - found := false - // read buffer into []string - var lines []string - scanner := bufio.NewScanner(c.currBuff) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - // search for entry - var anchors []int - for i, line := range lines { - reMatch := c.reSep.FindAllString(line, -1) - if reMatch != nil { - anchors = append(anchors, i) - } - if line == entry.name { - found = true - } - } - switch { - case found && replace: - lines = c.removeEntry(lines, anchors, entry) - // make the credEntry append itself to the results - lines = entry.appendToList(lines) - case found && !replace: - lines = c.removeEntry(lines, anchors, entry) - case !found && !replace: - // do nothing - case !found && replace: - lines = entry.appendToList(lines) - } - // now write []string to buffer adding newlines - for _, line := range lines { - c.currBuff.WriteString(fmt.Sprintf("%s\n", line)) - } - err = c.writeBufferToFile() - return err -} - -func (c *CredFile) fileExists() bool { - _, err := os.Stat(c.filename) - if os.IsNotExist(err) { - return false - } - return true -} - -func (c *CredFile) createFile() (bool, error) { - _, err := os.Create(c.filename) - if err != nil { - return false, err - } - return true, err -} diff --git a/acfmgr/acfmgr_test.go b/acfmgr/acfmgr_test.go deleted file mode 100644 index 3f7bb49..0000000 --- a/acfmgr/acfmgr_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package acfmgr - -import ( - "bytes" - "io/ioutil" - "os" - "testing" -) - -const baseCredFile string = ` -[testing] -foo -bar - -[newentry] -bar -foo - -` - -const expectedResult string = ` -[testing] -foo -bar - -[newentry] -bar -foo - -[acfmgrtest] -my -test -here -` - -const expectedResultDeletionEnd string = ` -[testing] -foo -bar - -` - -const expectedResultDeletionMiddle string = ` -[newentry] -bar -foo - -` - -func writeBaseFile(filename string) error { - var b bytes.Buffer - _, err := b.WriteString(baseCredFile) - err = ioutil.WriteFile(filename, b.Bytes(), 0644) - return err -} - -func TestModifyEntry(t *testing.T) { - filename := "./acfmgr_credfile_test.txt" - err := writeBaseFile(filename) - if err != nil { - t.Errorf("Error making basefile: %s", err) - } - sess, err := NewCredFileSession(filename) - if err != nil { - t.Errorf("Error making credfile session: %s", err) - } - entryName := "[acfmgrtest]" - entryContents := []string{"my", "test", "here"} - sess.NewEntry(entryName, entryContents) - err = sess.AssertEntries() - if err != nil { - t.Errorf("Error asserting entries: %s", err) - } - fullContents, err := ioutil.ReadFile(filename) - if err != nil { - t.Errorf("Error reading file: %s", err) - } - got := string(fullContents) - if got != expectedResult { - t.Errorf("Result not expected. Got: %s", got) - } - defer os.Remove(filename) -} - -func TestDeleteEntryAtEnd(t *testing.T) { - filename := "./acfmgr_credfile_test1.txt" - err := writeBaseFile(filename) - if err != nil { - t.Errorf("Error making basefile: %s", err) - } - sess, err := NewCredFileSession(filename) - if err != nil { - t.Errorf("Error making credfile session: %s", err) - } - entryName := "[newentry]" - entryContents := []string{"whocares"} - sess.NewEntry(entryName, entryContents) - err = sess.DeleteEntries() - if err != nil { - t.Errorf("Error deleting entries: %s", err) - } - fullContents, err := ioutil.ReadFile(filename) - if err != nil { - t.Errorf("Error reading file: %s", err) - } - got := string(fullContents) - if got != expectedResultDeletionEnd { - t.Errorf("Result not expected. Got: %s", got) - } - defer os.Remove(filename) -} - -func TestDeleteEntryInMiddle(t *testing.T) { - filename := "./acfmgr_credfile_test2.txt" - err := writeBaseFile(filename) - if err != nil { - t.Errorf("Error making basefile: %s", err) - } - sess, err := NewCredFileSession(filename) - if err != nil { - t.Errorf("Error making credfile session: %s", err) - } - entryName := "[testing]" - entryContents := []string{"whocares"} - sess.NewEntry(entryName, entryContents) - err = sess.DeleteEntries() - if err != nil { - t.Errorf("Error deleting entries: %s", err) - } - fullContents, err := ioutil.ReadFile(filename) - if err != nil { - t.Errorf("Error reading file: %s", err) - } - got := string(fullContents) - if got != expectedResultDeletionMiddle { - t.Errorf("Result not expected. Got: %s", got) - } - defer os.Remove(filename) -} diff --git a/etc/README.md b/etc/README.md deleted file mode 100644 index 381547c..0000000 --- a/etc/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Packaging - -This is experimental and incomplete. \ No newline at end of file diff --git a/etc/gossamer.init.d b/etc/gossamer.init.d deleted file mode 100644 index a4eafac..0000000 --- a/etc/gossamer.init.d +++ /dev/null @@ -1,99 +0,0 @@ -#!/bin/bash -# -# chkconfig: 35 95 05 -# description: gossamer daemon - -# Run at startup: sudo chkconfig gossamer on - -# Load functions from library -. /etc/init.d/functions - -# Name of the application -app="gossamer" - -# Start the service -run() { - echo -n $"Starting $app:" - cd /tmp - ./$app > /var/log/$app.log 2> /var/log/$app.err < /dev/null & - - sleep 1 - - status $app > /dev/null - # If application is running - if [[ $? -eq 0 ]]; then - # Store PID in lock file - echo $! > /var/lock/subsys/$app - success - echo - else - failure - echo - fi -} - -# Start the service -start() { - status $app > /dev/null - # If application is running - if [[ $? -eq 0 ]]; then - status $app - else - run - fi -} - -# Restart the service -stop() { - echo -n "Stopping $app: " - killproc $app - rm -f /var/lock/subsys/$app - echo -} - -# Reload the service -reload() { - status $app > /dev/null - # If application is running - if [[ $? -eq 0 ]]; then - echo -n $"Reloading $app:" - kill -HUP `pidof $app` - sleep 1 - status $app > /dev/null - # If application is running - if [[ $? -eq 0 ]]; then - success - echo - else - failure - echo - fi - else - run - fi -} - -# Main logic -case "$1" in - start) - start - ;; - stop) - stop - ;; - status) - status $app - ;; - restart) - stop - sleep 1 - start - ;; - reload) - reload - ;; - *) - echo $"Usage: $0 {start|stop|restart|reload|status}" - exit 1 -esac -exit 0 \ No newline at end of file diff --git a/etc/gossamer.spec b/etc/gossamer.spec deleted file mode 100644 index e9c7b29..0000000 --- a/etc/gossamer.spec +++ /dev/null @@ -1,37 +0,0 @@ -Name: gossamer -Version: 1.2.2.56 -Release: 0 -Summary: CLI app and daemon for constantly generating assume-role credentials. -Group: System Environment/Daemons -License: MIT -URL: https://github.com/rendicott/%{name} -Vendor: NA -Source: https://github.com/rendicott/%{name}/releases/download/v%{version}/%{name}-linux-amd64-%{version}.tar.gz -Prefix: %{_prefix} -Packager: Russell Endicott -BuildRoot: %{_tmppath}/%{name}-root - -%description -CLI app to help you manage assuming roles across AWS accounts. Two primary use cases: Can use a JSON list of ARNs and an MFA token to build assumed-role temporary credentials for roles in dozens of other accounts or it can run as a service to continuously build aws credentials file with sts assume-role token based on the instance profile. For example you can use an instance profile role to assume-role in another AWS account. - - - -%prep -%setup -q -n %{name}-linux-amd64-%{version}.tar.gz - - -%build -%configure - - - -%install -pwd - -%files -%doc - - - -%changelog - diff --git a/gossamer/config.go b/gossamer/config.go new file mode 100644 index 0000000..b68776a --- /dev/null +++ b/gossamer/config.go @@ -0,0 +1,723 @@ +package gossamer + +import ( + "bufio" + "errors" + "fmt" + "io/ioutil" + "os" + "regexp" + "strings" + "syscall" + + "github.com/GESkunkworks/acfmgr" + "github.com/GESkunkworks/gossamer/goslogger" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/sts" + + "golang.org/x/crypto/ssh/terminal" + "gopkg.in/yaml.v2" +) + +// GConf holds config and exports for use in other +// packages +var GConf Config + +// Config is an internal struct for storing +// configuration needed to run this application +type Config struct { + OutFile string `yaml:"output_file"` + Flows []*Flow `yaml:"flows"` +} +// Flow describes an authentication flow and can +// be one of many types. It contains the user's +// desired auth flow behavior via keys or saml. +type Flow struct { + Name string `yaml:"name"` + SAMLConfig *SAMLConfig `yaml:"saml_config,omitempty"` + PermCredsConfig *PermCredsConfig `yaml:"permanent,omitempty"` + PAss *Assumptions `yaml:"primary_assumptions,omitempty"` + SAss *Assumptions `yaml:"secondary_assumptions,omitempty"` + Region string `yaml:"region,omitempty"` + AllowFailure bool `yaml:"allow_failure"` + credsType string + roleSessionName string + // Key type properties + DoNotPropagateRegion bool `yaml:"do_not_propagate_region"` +} + +// PermCredsConfig holds information about how to obtain session +// credentials from the local client +type PermCredsConfig struct { + ProfileName string `yaml:"profile_name,omitempty"` + MFA *MFA `yaml:"mfa,omitempty"` +} + +func (pcc *PermCredsConfig) validate() (ok bool, err error) { + //TODO: Add validators + return ok, err +} + +// SAMLConfig holds specific parameters for SAML configuration +type SAMLConfig struct { + Username *CParam `yaml:"username"` + Password *CParam `yaml:"password"` + URL *CParam `yaml:"url"` + Target *CParam `yaml:"target"` + //TODO: support Duration +} + +func (sc *SAMLConfig) validate() (ok bool, err error) { + //TODO: Add some validation here + return ok, err +} + +// CParam provides a way to identify sources for config parameters +// that are more robust that simple key value. For example you can +// say that a configuration parameter is sourced from an environment +// variable or from a prompt in addition to just raw value. +// It has a gather() method which is used to retrieve its value. +type CParam struct { + name string + Source string `yaml:"source"` + Value string `yaml:"value,omitempty"` + // unexported fields + gathered bool + result string + parentflow string +} + +// gather looks at the source of the config parameter +// and attempts to retrieve the value using that method. +// It returns the value as a string and any errors. +func (c *CParam) gather() (val string, err error) { + // if we've already grabbed it in the past + // we'll just return it again + if c.gathered { + return c.result, err + } + // otherwise we'll collect + switch c.Source { + case "config": + switch c.name { + case "Password": + msg := fmt.Sprintf("%s %s", + "this program does not support putting password in plaintext in config file", + "please switch config parameter for password to 'env' or 'prompt'", + ) + err = errors.New(msg) + return val, err + } + c.gathered = true + c.result = c.Value + return c.Value, err + case "env": + c.result = os.Getenv(c.Value) + if len(c.result) < 1 { + message := fmt.Sprintf("env var '%s' specified for param is empty", c.Value) + err = errors.New(message) + } + c.gathered = true + return c.result, err + case "prompt": + fmt.Printf("gathering value for flow '%s': ", c.parentflow) + switch c.name { + case "Password": + c.result, err = getSecretFromUser(c.name) + default: + c.result, err = getValueFromUser(c.name) + } + return c.result, err + } + // default to sending blank and an error if it got here + message := fmt.Sprintf("config parameter '%s' unknown", c.Source) + err = errors.New(message) + val = "" + return val, err +} + +// Assumptions holds the configuration for the roles that +// will be assumed using both the primary and secondary credentials +// Primary: +// In the case of SAML that's the roles in the assertion. +// In the case of key and key-mfa it's the roles that will be assumed directly +// Secondary: +// Secondary assumptions' mappings rely on sponsor credentials +// that are presumed to be obtained from primary mappings +type Assumptions struct { + AllRoles bool `yaml:"all_roles"` + Mappings []Mapping `yaml:"mappings"` + doNotPropagateRegion bool + roleSessionName string + parentRegion string + parentFlow string + allowFailure bool +} + +func (ap *Assumptions) setRoleSessionName(name string) { + ap.roleSessionName = name +} + +func (a *Assumptions) getRoleSessionName() *string { + return (&a.roleSessionName) +} + +// convertSCredstoCreds converts credentials from the sts to the credentials package +// per the specifications of the golan AWS SDK +func convertSCredsToCreds(screds *sts.Credentials) (creds *credentials.Credentials) { + creds = credentials.NewStaticCredentials( + *screds.AccessKeyId, + *screds.SecretAccessKey, + *screds.SessionToken) + return creds +} + +// NoSAss returns false if the flow has any secondary assumptions defined +// and true if not. +func (f *Flow) NoSAss() bool { + if f.SAss != nil { + return false + } + return true +} + +// Mapping holds the configuration for role assumptions +// and their desired profile name to be written to the +// credentials file after they've been assumed. +type Mapping struct { + RoleArn string `yaml:"role_arn"` + ProfileName string `yaml:"profile_name,omitempty"` + Region string `yaml:"region,omitempty"` + NoOutput bool `yaml:"no_output,omitempty"` + SponsorCredsArn string `yaml:"sponsor_creds_arn,omitempty"` + credential *sts.Credentials + //TODO: support Duration +} + +func (m *Mapping) getCredential() (cred *sts.Credentials, err error) { + if m.credential == nil { + msg := fmt.Sprintf("credential is nil for %s", m.RoleArn) + err = errors.New(msg) + } + cred = m.credential + return cred, err + +} + +func (a *Assumptions) getMappingCredential(roleArn string) (cred *sts.Credentials, err error) { + found := false + for _, mapping := range a.Mappings { + if mapping.RoleArn == roleArn { + found = true + cred, err = mapping.getCredential() + } + } + if !found { + msg := fmt.Sprintf("credentials not found for %s", roleArn) + err = errors.New(msg) + } + return cred, err +} + +// func (m *Mapping) setCredential(cred *sts.Credentials) (err error) { +// if cred == nil { +// msg := fmt.Sprintf("incoming credential is nil") +// err = errors.New(msg) +// } +// m.credential = cred +// return err +// +// } + +// validate checks for common things that always need to be done +// to mappings before they can be written out +func (m *Mapping) validate(strict bool) (err error) { + if len(m.ProfileName) < 1 { + goslogger.Loggo.Debug("detected missing profile name", "roleArn", m.RoleArn) + uid, err := getRoleUniqueId(m.RoleArn) + if err != nil { + return err + } + m.ProfileName = *uid + goslogger.Loggo.Debug("set profilename", "profileName", m.ProfileName) + } + if strict { + _, err = m.getCredential() + } + return err +} + +func (m *Mapping) setRegionIfNotSet(region string) { + if len(m.Region) < 1 { + m.Region = region + } +} + +// validateMappings checks all mappings within a set of +// assumptions to make sure it has common things set +func (a *Assumptions) validateMappings(strict bool) (err error) { + goslogger.Loggo.Debug("validating mappings in assumptions", + "numMappings", len(a.Mappings), + "parentFlow", a.parentFlow, + ) + for i := range a.Mappings { + err = a.Mappings[i].validate(strict) + if err != nil { + return err + } + } + if !a.doNotPropagateRegion && len(a.parentRegion) > 0 { + goslogger.Loggo.Info("propagating region from flow to assumption mappings", "parentFlow", a.parentFlow) + for i := range a.Mappings { + a.Mappings[i].setRegionIfNotSet(a.parentRegion) + } + } else { + goslogger.Loggo.Debug("not setting parentRegion on assumptions", + "parentFlow", a.parentFlow, + "a.doNotPropagateRegion", a.doNotPropagateRegion, + "len(parentRegion)", len(a.parentRegion), + ) + } + return err +} + +// dump spits back some basic info about the mapping +// useful during debugging +func (m *Mapping) dump() string { + return fmt.Sprintf("RoleArn: %s\nProfileName: %s\ncredential: %s\n", m.RoleArn, m.ProfileName, *m.credential.AccessKeyId) +} + +func (m *Assumptions) getMapping(roleArn string) (ok bool, mappingResult *Mapping) { + for _, mapping := range m.Mappings { + if mapping.RoleArn == roleArn { + mappingResult = &mapping + ok = true + return ok, mappingResult + } + } + return ok, mappingResult +} + +func (m *Assumptions) setMappingCredential(roleArn string, cred *sts.Credentials) (ok bool) { + for i := range m.Mappings { + if m.Mappings[i].RoleArn == roleArn { + m.Mappings[i].credential = cred + return ok + } + } + return ok +} + +func (m *Assumptions) setMappingProfileName(roleArn, name string) (ok bool) { + for i := range m.Mappings { + if m.Mappings[i].RoleArn == roleArn { + m.Mappings[i].ProfileName = name + return ok + } + } + return ok +} + +// buildMappings builds []*Mappings slice for the session when the mappings are not known +// ahead of time and can't be parsed from the config file. This will generally come in as +// the result of a SAML Assertion where a bunch of roles come in and we want to try and +// map them to a profile name or region based on their ARN. This mapping is defined in the +// config file so we do the conversion here. +func (m *Assumptions) buildMappings(mappings []*Mapping) (err error) { + for _, wmapping := range mappings { + ok, mapping := m.getMapping(wmapping.RoleArn) + if ok { + goslogger.Loggo.Debug("buildMappings: found mapping", "mapping", mapping.RoleArn) + // means we know about the role already and just need the creds + // and maybe the profile name if we don't have one. + if len(mapping.ProfileName) < 1 { + m.setMappingProfileName(mapping.RoleArn, wmapping.ProfileName) + } + m.setMappingCredential(mapping.RoleArn, wmapping.credential) + } + if !ok && m.AllRoles { + if !ok { + // means its totally new to us so we just take whatever we get + goslogger.Loggo.Debug("buildMappings: new mapping", "mapping", wmapping.RoleArn) + newMapping := Mapping{ + RoleArn: wmapping.RoleArn, + ProfileName: wmapping.ProfileName, + credential: wmapping.credential, + } + m.Mappings = append(m.Mappings, newMapping) + } + } + } + return err +} + +// GetAcfmgrProfileInputs converts mappings into Acfmgr ProfileEntryInput for easy use with AcfMgr package +func (a *Assumptions) GetAcfmgrProfileInputs() (pfis []*acfmgr.ProfileEntryInput, err error) { + count_success := 0 + count_fail := 0 + total := len(pfis) + goslogger.Loggo.Debug("entering GetAcfmgrProfileInputs()...") + for _, mapping := range a.Mappings { + if !mapping.NoOutput { + cred, err := mapping.getCredential() + if err != nil { + goslogger.Loggo.Error("error retrieiving credential", "error", err) + count_fail++ + } else { + profileInput := acfmgr.ProfileEntryInput{ + Credential: cred, + ProfileEntryName: mapping.ProfileName, + Region: mapping.Region, + AssumeRoleARN: mapping.RoleArn, + Description: a.parentFlow, + } + pfis = append(pfis, &profileInput) + goslogger.Loggo.Debug("put credential in write queue", + "RoleArn", mapping.RoleArn, + "ProfileName", mapping.ProfileName, + "cred", *profileInput.Credential.AccessKeyId, + ) + count_success++ + } + } else { + goslogger.Loggo.Info("Skipping writing cred per configuration directive", "roleArn", mapping.RoleArn) + } + } + if count_success < total { + goslogger.Loggo.Info("failed to obtain some credentials to add to write queue", "total", total, "count_fail", count_fail, "count_success", count_success) + } + if count_success == 0 { + if !a.allowFailure { + msg := fmt.Sprintf("failed to queue any desired credentials") + err = errors.New(msg) + } + } + return pfis, err +} + +// getListOfArns gets a list of the role arns in the mappings and returns it +func (m *Assumptions) getListOfArns() (roles []string) { + for _, mapping := range(m.Mappings) { + roles = append(roles, mapping.RoleArn) + } + return roles +} + +// MFA holds configuration information for the MFA device +// during a key based auth flow. +type MFA struct { + Serial *CParam `yaml:"serial"` + Token *CParam `yaml:"token"` +} + +// KeySource holds the configuration information for +// where they key credentials are located. Options are +// profile or default. "profile" will pull from the desired profile +// in the credentials file. "default" will follow the +// standard credentials search order as defined by AWS. +// KeySource will be used during the flow's GetSession() +// method to provide a session. +type KeySource struct { + SourceType string `yaml:"source_type"` + ProfileName string `yaml:"profile_name"` +} + +func (a *Assumptions) setParentRegion(region string) { + a.parentRegion = region +} + +func (a *Assumptions) setDoNotPropagateRegion(dnp bool) { + a.doNotPropagateRegion = dnp +} + +// Validate checks for valid properties and returns +// true if no problems are detected and false with an +// error message if there are issues +func (f *Flow) Validate() (valid bool, err error) { + // first detect type + switch { + case f.SAMLConfig != nil && f.PermCredsConfig == nil: + f.credsType = "saml" + valid, err = f.SAMLConfig.validate() + if err != nil {return valid, err} + case f.SAMLConfig == nil && f.PermCredsConfig != nil: + f.credsType = "permanent" + valid, err = f.PermCredsConfig.validate() + if err != nil {return valid, err} + default: + err = errors.New("only one type of creds can be used for starting each flow please choose one of: permanent or saml") + return valid, err + } + goslogger.Loggo.Info("detected type for flow", "flowName", f.Name, "type", f.credsType) + if len(f.Region) > 1 { + goslogger.Loggo.Info("flow: detected user specified region so validating it") + var validRegion = regexp.MustCompile(`\w{2}-([a-z]*-){1,2}\d{1}`) + if validRegion.MatchString(f.Region) { + valid = true + } else { + err = errors.New("region must match '\\w{2}-([a-z]*-){1,2}\\d{1}'") + return valid, err + } + } + // set parentRegion and inheritance setting on assumptions if set on flow + if f.PAss != nil { + f.PAss.parentFlow = f.Name + if !f.DoNotPropagateRegion && len(f.Region) > 0 { + goslogger.Loggo.Info("setting parent region on primary assumptions", "flow", f.Name) + f.PAss.setParentRegion(f.Region) + } else { + f.PAss.setDoNotPropagateRegion(true) + } + if f.AllowFailure { + f.PAss.allowFailure = true + } + } + if f.SAss != nil { + f.SAss.parentFlow = f.Name + if !f.DoNotPropagateRegion && len(f.Region) > 0 { + goslogger.Loggo.Info("setting parent region on secondary assumptions", "flow", f.Name) + f.SAss.setParentRegion(f.Region) + } else { + f.SAss.setDoNotPropagateRegion(true) + } + if f.AllowFailure { + f.SAss.allowFailure = true + } + } + return valid, err +} + +// ParseConfigFile takes a yaml filename as input and +// attempts to parse it into a config object. +func (gc *Config) ParseConfigFile(filename string) (err error) { + yamlFile, err := ioutil.ReadFile(filename) + if err != nil { + return err + } + err = yaml.Unmarshal(yamlFile, gc) + // add labels to CParams so we can sanely prompt for them + for _, flow := range gc.Flows { + if flow.SAMLConfig != nil { + flow.SAMLConfig.Username.name = "Username" + flow.SAMLConfig.Username.parentflow = flow.Name + + flow.SAMLConfig.Password.name = "Password" + flow.SAMLConfig.Password.parentflow = flow.Name + + flow.SAMLConfig.URL.name = "URL" + flow.SAMLConfig.URL.parentflow = flow.Name + + flow.SAMLConfig.Target.name = "Target" + flow.SAMLConfig.Target.parentflow = flow.Name + } + if flow.PermCredsConfig != nil { + if flow.PermCredsConfig.MFA != nil { + flow.PermCredsConfig.MFA.Serial.name = "Serial" + flow.PermCredsConfig.MFA.Serial.parentflow = flow.Name + + flow.PermCredsConfig.MFA.Token.name = "Token" + flow.PermCredsConfig.MFA.Token.parentflow = flow.Name + } + } + } + return err +} + +// Dump returns a string of the full parsed configuration +func (gc *Config) Dump() string { + var r []byte + r, _ = yaml.Marshal(gc) + return string(r) +} + +// getSecretFromUser grabs input from user for single string +// thanks to stackoverflow poster gihanchanuka +// https://stackoverflow.com/questions/2137357/getpasswd-functionality-in-go +func getSecretFromUser(label string) (valueHidden string, err error) { + fmt.Printf("Enter value for '%s' (hidden): ", label) + bytePassword, err := terminal.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return valueHidden, err + } + valueHidden = strings.TrimSpace(string(bytePassword)) + return valueHidden, err +} + +// getValueFromUser grabs input from user for single string +// thanks to stackoverflow poster gihanchanuka +// https://stackoverflow.com/questions/2137357/getpasswd-functionality-in-go +func getValueFromUser(label string) (value string, err error) { + reader := bufio.NewReader(os.Stdin) + fmt.Printf("Enter value for '%s': ", label) + + value, err = reader.ReadString('\n') + if err != nil { + return value, err + } + value = strings.TrimSpace(value) + return value, err +} + +// awsEnvSet returns true if any of the common AWS_* environment variables are set +func awsEnvSet() (bool) { + commonVars := []string{ + "AWS_ACCESS_KEY_ID", + "AWS_PROFILE", + "AWS_ROLE_SESSION_NAME", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + } + for _, cvar := range(commonVars) { + t := os.Getenv(cvar) + if t != "" { + return true + } + } + return false +} + +// getPermSession looks at the flow's configuration settings and attempts to +// work out how to return the credentials. +func (f *Flow) getPermSession() (sess *session.Session, err error) { + goslogger.Loggo.Info("getting session from permanent credentials", "flowname", f.Name) + if f.PermCredsConfig != nil { + if len(f.PermCredsConfig.ProfileName) > 0 && len(f.Region) > 0 { + goslogger.Loggo.Debug("using profile for session with specific region", "flowname", f.Name) + if awsEnvSet() { + goslogger.Loggo.Info("WARNING: some AWS_* environment variables are set that may interfere with profile session establishment") + } + sess = session.Must(session.NewSessionWithOptions(session.Options{ + Config: aws.Config{Region: &f.Region}, + Profile: f.PermCredsConfig.ProfileName, + })) + } else if len(f.PermCredsConfig.ProfileName) > 0 { + goslogger.Loggo.Debug("using profile for session", "flowname", f.Name) + if awsEnvSet() { + goslogger.Loggo.Info("WARNING: some AWS_* environment variables are set that may interfere with profile session establishment") + } + sess, err = session.NewSessionWithOptions(session.Options{ + Profile: f.PermCredsConfig.ProfileName, + }) + if err != nil { return sess, err } + } else { + // just try default session establish which should use + // environment variables, instance profile, etc. in the + // AWS published order. (https://docs.aws.amazon.com/sdk-for-go/api/aws/session/#Session) + // + // * Environment Variables + // * Shared Credentials file + // * Shared Configuration file (if SharedConfig is enabled) + // * EC2 Instance Metadata (credentials only) + goslogger.Loggo.Info("no profile specified so attempting default cred loader from ENV vars, etc", "flowname", f.Name) + sess, err = session.NewSession() + if err != nil { + return sess, err + } + } + } + if sess == nil { + err = errors.New("unable to establish initial session") + return sess, err + } + // try to get the role session name from the session we just got + // because we want the pure name before the MFA session if any + f.PAss.setRoleSessionName(generateRoleSessionName(sess)) + // now we need to check and see if we need to establish MFA on the session + goslogger.Loggo.Debug("checking for presence of MFA") + if f.PermCredsConfig.MFA != nil { + goslogger.Loggo.Debug("got raw serial and token", "serial", f.PermCredsConfig.MFA.Serial.Value, "token", f.PermCredsConfig.MFA.Token.Value) + serial, err := f.PermCredsConfig.MFA.Serial.gather() + if err != nil {return sess, err} + token, err := f.PermCredsConfig.MFA.Token.gather() + if err != nil {return sess, err} + goslogger.Loggo.Debug("got gathered serial and token", "serial", serial, "token", token) + gstInput := &sts.GetSessionTokenInput{ + //TODO: add duration support + SerialNumber: &serial, + TokenCode: &token, + } + svcSTS := sts.New(sess) + gstOutput, err := svcSTS.GetSessionToken(gstInput) + if err != nil { return sess, err } + // build the credentials.cred object manually because the structs are diff. + statCreds := convertSCredsToCreds(gstOutput.Credentials) + if len(f.Region) > 0 { + sess = session.Must(session.NewSessionWithOptions(session.Options{ + Config: aws.Config{Credentials: statCreds, Region: &f.Region}, + })) + } else { + sess = session.Must(session.NewSessionWithOptions(session.Options{ + Config: aws.Config{Credentials: statCreds,}, + })) + } + } + return sess, err +} + +// generateRoleSessionName runs a GetCallerIdentity API call +// to try and auto generate the role session name from an +// established session. +func generateRoleSessionName(sess *session.Session) string { + client := sts.New(sess) + callerIdentity, err := client.GetCallerIdentity(&sts.GetCallerIdentityInput{}) + if err != nil { + return "gossamer" + } + arnParts := strings.Split(*callerIdentity.Arn, "/") + return "gossamer-" + arnParts[len(arnParts)-1] +} + +// validate checks for valid properties and returns +// true if no problems are detected and false with an +// error message if there are issues +func (k *KeySource) validate() (valid bool, err error) { + if k.SourceType == "default" || k.SourceType == "profile" { + valid = true + } else { + err = errors.New("key source source type must be of one 'profile' or 'default'") + return valid, err + } + if k.SourceType == "profile" { + if len(k.ProfileName) < 1 { + err = errors.New("when using key source type 'profile' must specify profile_name") + return valid, err + } + } + return valid, err +} + +// assumeRoleWithSession takes an existing session and sets up the assume role inputs for +// the API call +func assumeRoleWithSession(roleArn, roleSessionName *string, sess *session.Session) (*sts.Credentials, error) { + client := sts.New(sess) + input := sts.AssumeRoleInput{ + RoleArn: roleArn, + RoleSessionName: roleSessionName, + //TODO: Add duration + } + aso, err := client.AssumeRole(&input) + return aso.Credentials, err +} + +type GossFlags struct { + ConfigFile string + RolesFile string + OutFile string + RoleArn string + LogFile string + LogLevel string + GeneratedConfigOutputFile string + DaemonFlag bool + Profile string + SerialNumber string + TokenCode string + Region string + ProfileEntryName string + SessionDuration int64 + VersionFlag bool + ForceRefresh bool +} + diff --git a/gossamer/getas.go b/gossamer/getas.go new file mode 100644 index 0000000..cccd493 --- /dev/null +++ b/gossamer/getas.go @@ -0,0 +1,155 @@ +package gossamer + +import ( + "errors" + "fmt" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/aws" + "github.com/GESkunkworks/gossamer/goslogger" +) + + +// GetPAss handles the primary assumptions when using traditional keys +func (f *Flow) GetPAss() (err error) { + sess, err := f.getPermSession() + if err != nil { return err } + for _, mapping := range(f.PAss.Mappings) { + cred, err := assumeRoleWithSession(&mapping.RoleArn, f.PAss.getRoleSessionName(), sess) + if err != nil { + goslogger.Loggo.Error("Error assuming primary mapping", + "PrimaryMapping", mapping.RoleArn, + "flowType", f.credsType, + "Error", err, + ) + } else { + f.PAss.setMappingCredential(mapping.RoleArn, cred) + goslogger.Loggo.Info("Successfully assumed Primary Mapping", + "mapping", mapping.RoleArn, + "flowType", f.credsType, + "cred", *cred.AccessKeyId, + ) + } + } + strict := false + err = f.PAss.validateMappings(strict) + return err +} + +// GetPAssSAML handles the SAML assumptions using the current desird configuration from the flow +func (f *Flow) GetPAssSAML() (err error) { + samluser, err := f.SAMLConfig.Username.gather() + if err != nil {return err} + samlpass, err := f.SAMLConfig.Password.gather() + if err != nil {return err} + samlurl, err := f.SAMLConfig.URL.gather() + if err != nil {return err} + samltarget, err := f.SAMLConfig.Target.gather() + if err != nil {return err} + + sc := newSAMLSessionConfig(f.Name, samluser, samlpass, samlurl, samltarget) + err = sc.startSAMLSession() + if err != nil {return err} + + rolesResult, err := sc.assumeSAMLRoles(f.PAss.AllRoles, f.PAss.getListOfArns()) + if err != nil {return err} + goslogger.Loggo.Debug("flow > saml > AssumeSAMLRoles: done") + // set the session name for later in case we need it for secondary assumptions + f.roleSessionName = *sc.RoleSessionName + + err = f.PAss.buildMappings(rolesResult) + if err != nil {return err} + + strict := false + err = f.PAss.validateMappings(strict) + if err != nil {return err} + goslogger.Loggo.Debug("flow > saml > BuildMappings: done") + return err +} + +// ExecutePrimary runs the appropriate steps to complete the Primary Assumptions +// auth flow for the detected flow type +func (f *Flow) ExecutePrimary() (err error) { + switch f.credsType { + case "saml": + err = f.GetPAssSAML() + if err != nil {return err} + case "permanent": + err = f.GetPAss() + if err != nil {return err} + default: + err = errors.New("unable to determine flow type") + } + return err +} + + +// GetSAss goes through all of the secondary assumptions (if any) and collects credentials +// it's very lenient and only returns errors if they are critical. +func (f *Flow) GetSAss() (masterErr error) { + goslogger.Loggo.Debug("starting flow > GetSAss", "name", f.Name) + if !f.NoSAss() { + // first we need to make absolutely sure we carry over the RoleSessionName for security purposes. + rsn := f.PAss.getRoleSessionName() + f.SAss.setRoleSessionName(*rsn) + for _, sapping := range f.SAss.Mappings { + var sponsorCred *sts.Credentials + var err error + if len(sapping.SponsorCredsArn) < 1 && len(f.PAss.Mappings) > 1 { + // means we can't make any inferences + msg := fmt.Sprintf("no sponsor_creds_arn specified for secondary mapping '%s' and too many primary mappings to make an inference", sapping.RoleArn) + err = errors.New(msg) + } else if len(sapping.SponsorCredsArn) < 1 { + goslogger.Loggo.Debug("detected missing sponsor creds arn in secondary mapping") + // means user didn't put anything in config file for sponsor creds + // however, if there's only one set of primary creds we can infer + if len(f.PAss.Mappings) == 1 { + goslogger.Loggo.Debug("since only one set of creds in primary assumptions we'll take sponsorcreds from there") + sponsorCred, err = f.PAss.getMappingCredential(f.PAss.Mappings[0].RoleArn) + } + } else { + sponsorCred, err = f.PAss.getMappingCredential(sapping.SponsorCredsArn) + } + if err != nil { + goslogger.Loggo.Error( + "Error with getting sponsor credentials, skipping SecondaryMapping", + "SponsorCredsArn", sapping.SponsorCredsArn, + "SecondaryMapping", sapping.RoleArn, + "error", err, + ) + } else { + sess, err := session.NewSessionWithOptions(session.Options{ + Config: aws.Config{Credentials: convertSCredsToCreds(sponsorCred)}, + }) + if err != nil { + goslogger.Loggo.Error("Error establishing session with sponsorCreds", + "Error", err, + ) + } else { + cred, err := assumeRoleWithSession(&sapping.RoleArn, f.SAss.getRoleSessionName(), sess) + if err != nil { + goslogger.Loggo.Error("Error assuming secondary mapping", + "SecondaryMapping", sapping.RoleArn, + "Error", err, + ) + } else { + f.SAss.setMappingCredential(sapping.RoleArn, cred) + goslogger.Loggo.Info("Successfully assumed Secondary Mapping", + "mapping", sapping.RoleArn, + "SponsorCredsArn", sapping.SponsorCredsArn, + "cred", *cred.AccessKeyId, + ) + } + } + } + } + // now validate everything but we'll be lenient with creds + strict := false + masterErr = f.SAss.validateMappings(strict) + } else { + goslogger.Loggo.Info("no secondary assumptions detected so skipping", "flowname", f.Name) + } + return masterErr +} + + diff --git a/gossamer/gossamer.go b/gossamer/gossamer.go deleted file mode 100644 index a3eb578..0000000 --- a/gossamer/gossamer.go +++ /dev/null @@ -1,484 +0,0 @@ -package gossamer - -/* -Build aws credentials file with sts assume-role token based on the instance profile or assume role from a list of accounts with an MFA token. - -Specifically designed for an instance profile role to assume-role in another AWS account. - -Example: -go run gossamer.go -o ./test.txt -a arn:aws:iam::123456789101:role/collectd-cloudwatch-putter -*/ - -import ( - "bufio" - "bytes" - "encoding/json" - "fmt" - "io" - "os" - "regexp" - "strings" - "text/template" - "time" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/credentials" - "github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds" - "github.com/aws/aws-sdk-go/aws/ec2metadata" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/sts" - - "github.com/GESkunkworks/gossamer/acfmgr" - "github.com/GESkunkworks/gossamer/goslogger" -) - -// sample 2017-05-01 23:53:42 +0000 UTC -const dateFormat = "2006-01-02 15:04:05 -0700 MST" -const reDateFormat = `[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} (\-|\+)[0-9]{4} \w{3}` - -// what to search for in the creds file to determine expiration -const expiresToken = "# EXPIRES@" -const credFileTemplate = `# DO NOT EDIT -# GOSSAMER MANAGED SECTION -# (Will be overwritten regularly) -#################################################### -# ASSUMED ROLE: {{.AssumeRoleARN}} -# ASSUMED FROM INSTANCE ROLE: {{.InstanceRoleARN}} -# GENERATED: {{.Generated}} -{{ .ExpiresToken }}{{.Expiration}} -output = json -region = {{.Region}} -aws_access_key_id = {{.AccessKeyID}} -aws_secret_access_key = {{.SecretAccessKey}} -aws_session_token = {{.SessionToken}} -` - -func haveCredsWillWrite(creds *sts.Credentials, opts *RunnerOptions, instanceProfileArn string, acctCurrent Account) (err error) { - goslogger.Loggo.Debug("entered function", "function", "haveCredsWillWrite") - // if instance-profile then we'll just look in the meta for the region - // and overwrite the default or what the user put in - if opts.Mode == "instance-profile" { - var errr error - opts.Region, errr = getRegion() - if errr != nil { - return errr - } - } - // build a struct for templating aws creds file - type basicCredential struct { - AccessKeyID string - SecretAccessKey string - SessionToken string - Expiration string - Generated string - Region string - ExpiresToken string - InstanceRoleARN string - AssumeRoleARN string - } - - // assign values to struct - baseCreds := basicCredential{ - AccessKeyID: *creds.AccessKeyId, - SecretAccessKey: *creds.SecretAccessKey, - SessionToken: *creds.SessionToken, - Expiration: creds.Expiration.String(), - Generated: time.Now().String(), - Region: acctCurrent.Region, - ExpiresToken: expiresToken, - InstanceRoleARN: instanceProfileArn, - AssumeRoleARN: acctCurrent.RoleArn, - } - // build and write the aws creds file based on the template - tmpl, err := template.New("test").Parse(credFileTemplate) - if err != nil { - return err - } - goslogger.Loggo.Debug("About to write creds file") - // make a buffer to hold templated string - buf := new(bytes.Buffer) - err = tmpl.Execute(buf, baseCreds) - // build the acfmgr cred file session - credContents := strings.Split(buf.String(), "\n") - c, err := acfmgr.NewCredFileSession(opts.OutFile) - credName := "[" + acctCurrent.AccountName + "]" - c.NewEntry(credName, credContents) - err = c.AssertEntries() - goslogger.Loggo.Info("Wrote new credentials file.", "path", opts.OutFile) - if err != nil { - return err - } - return err -} - -func assumer(profile *sts.STS, opts *RunnerOptions, acct Account, useToken bool) error { - - // the params we'll need for assume-role with mfa - var params *sts.AssumeRoleInput - if useToken { - params = &sts.AssumeRoleInput{ - RoleArn: &acct.RoleArn, - RoleSessionName: &opts.RoleSessionName, - DurationSeconds: &opts.SessionDuration, - SerialNumber: &opts.SerialNumber, - TokenCode: &opts.TokenCode, - } - } else { - params = &sts.AssumeRoleInput{ - RoleArn: &acct.RoleArn, - RoleSessionName: &opts.RoleSessionName, - DurationSeconds: &opts.SessionDuration, - } - } - - // now try the assume-role with the loaded creds - resp, errr := profile.AssumeRole(params) - if errr != nil { - return errr - } - - // Log the response data. Truncate for security - goslogger.Loggo.Info("Response from AssumeRole", "AccessKeyId", *resp.Credentials.AccessKeyId, - "AccountName", fmt.Sprintf("%s", acct.AccountName), - "RoleArn", fmt.Sprintf("%s", acct.RoleArn), - "Expiration", resp.Credentials.Expiration.String()) - - instanceProfileArn := "NA" - errr = haveCredsWillWrite(resp.Credentials, opts, instanceProfileArn, acct) - return errr -} - -// determineExpired takes a date-time string and parses it then compares -// against the desired renew threshold and returns true if the expiration -// is outside of the threshold. Returns boolean true if need to renew or -// false if no need to renew. -func determineExpired(dateString string, renewThreshold float64) bool { - goslogger.Loggo.Debug("entered function", "function", "determineExpired") - timeExpire, err := time.Parse(dateFormat, dateString) - if err != nil { - panic(err) - } - duration := time.Since(timeExpire) - goslogger.Loggo.Info("Token expiration check", "ExpiresIn", -duration.Minutes(), "renewThreshold", renewThreshold) - if -duration.Minutes() < renewThreshold { - return true - } - return false -} - -// configuration holds a list of -// account structs -type configuration struct { - Roles []Account -} - -func getRegion() (mr string, errrr error) { - goslogger.Loggo.Debug("entered function", "function", "getRegion") - // First grab current session to call metadata - sess := session.Must(session.NewSession()) - meta := ec2metadata.New(sess) - mr, errrr = meta.Region() - if errrr != nil { - return mr, errrr - } - return mr, errrr -} - -// GenerateNewProfile modifies an aws config file based on the desired -// profile. It uses assume-role and returns an error. -func GenerateNewProfile(opts *RunnerOptions, accounts []Account) (err error) { - goslogger.Loggo.Debug("entered function", "function", "generateNewProfile") - // now grab creds from profile file - creds := credentials.NewSharedCredentials(opts.OutFile, opts.Profile) - cval, errrr := creds.Get() - if errrr != nil { - return errrr - } - goslogger.Loggo.Debug("iamcreds", "creds", cval) - - sessProfile := session.Must(session.NewSessionWithOptions(session.Options{ - Config: aws.Config{Credentials: creds, Region: &opts.Region}, - })) - svcProfile := sts.New(sessProfile) - - for _, acct := range accounts { - goslogger.Loggo.Debug("working on account", - "AccountName", acct.AccountName, - "RoleArn", acct.RoleArn) - err = assumer(svcProfile, opts, acct, false) - if err != nil { - return err - } - } - goslogger.Loggo.Info("GenerateNewProfile wrote credentials", "numberOfCredentialsWritten", len(accounts)) - return err -} - -// Generate the role session name -func generateRoleSessionName(client *sts.STS) string { - callerIdentity, err := client.GetCallerIdentity(&sts.GetCallerIdentityInput{}) - if err != nil { - return "gossamer" - } - arnParts := strings.Split(*callerIdentity.Arn, "/") - return "gossamer-" + arnParts[len(arnParts)-1] -} - -// GenerateNewMfa modifies an aws config file based on the desired -// profile and provided mfa code. It uses assume-role and returns an error. -func GenerateNewMfa(opts *RunnerOptions, accounts []Account) (err error) { - goslogger.Loggo.Debug("entered function", "function", "generateNewMfa") - // now grab creds from profile file - creds := credentials.NewSharedCredentials(opts.OutFile, opts.Profile) - cval, errrr := creds.Get() - if errrr != nil { - return errrr - } - goslogger.Loggo.Debug("iamcreds", "creds", cval) - - sessProfile := session.Must(session.NewSessionWithOptions(session.Options{ - Config: aws.Config{Credentials: creds, Region: &opts.Region}, - })) - svcProfile := sts.New(sessProfile) - - // Update the role session name - opts.RoleSessionName = generateRoleSessionName(svcProfile) - - count := 0 - if len(accounts) > 1 { - // means we have to get an sts session first then do the assumes - gstInput := &sts.GetSessionTokenInput{ - DurationSeconds: &opts.SessionDuration, - SerialNumber: &opts.SerialNumber, - TokenCode: &opts.TokenCode, - } - - gstOutput, err := svcProfile.GetSessionToken(gstInput) - // goslogger.Loggo.Debug("Get session token result...", "gstOutput.Credentials", gstOutput.Credentials) - if err != nil { - goslogger.Loggo.Crit("Error in getSessionToken", "error", err) - return err - } - // build the credentials.cred object manually because the structs are diff. - statCreds := credentials.NewStaticCredentials( - *gstOutput.Credentials.AccessKeyId, - *gstOutput.Credentials.SecretAccessKey, - *gstOutput.Credentials.SessionToken) - sessMfa := session.Must(session.NewSessionWithOptions(session.Options{ - Config: aws.Config{Credentials: statCreds, Region: &opts.Region}, - })) - // now using the new session we can open another sts session - svcMfa := sts.New(sessMfa) - for _, acct := range accounts { - goslogger.Loggo.Info("working on account", - "AccountName", acct.AccountName, - "RoleArn", acct.RoleArn) - if opts.Mode == "mfa" { - err = assumer(svcMfa, opts, acct, false) - } else if opts.Mode == "mfa_noassume" { - err = haveCredsWillWrite(gstOutput.Credentials, opts, "NA", acct) - } - if err != nil { - handleGenErr(err) - } - } - } else { - // means user only passed one so we can do the - // single shot assumption which supports longer - // sessionDuration - for _, acct := range accounts { - goslogger.Loggo.Info("generating with token for single account", - "AccountName", acct.AccountName, - "RoleArn", acct.RoleArn) - err = assumer(svcProfile, opts, acct, true) - if err != nil { - handleGenErr(err) - } - count++ - } - } - - goslogger.Loggo.Info("GenerateNewMfa wrote credentials", "numberOfCredentialsWritten", count) - return err -} - -func handleGenErr(err error) { - if err != nil { - goslogger.Loggo.Error("Error generating cred", "error", err) - } -} - -// GenerateNewMeta builds a credentials type from instance metadata -// for use in generateNew -func GenerateNewMeta(opts *RunnerOptions, acctCurrent Account) (errr error) { - goslogger.Loggo.Debug("entered function", "function", "generatedNewMeta") - // First grab current session to call metadata - sess := session.Must(session.NewSession()) - meta := ec2metadata.New(sess) - // get current IAM info for debug - info, errr := meta.IAMInfo() - instanceProfileArn := info.InstanceProfileArn - instanceProfileID := info.InstanceProfileID - goslogger.Loggo.Info("Got info from metadata service", - "instanceProfileArn", - instanceProfileArn, - "instanceProfileID", instanceProfileID) - // now grab creds from instance profile metadata session - creds := ec2rolecreds.NewCredentialsWithClient(meta) - - sessProfile := session.Must(session.NewSessionWithOptions(session.Options{ - Config: aws.Config{Credentials: creds, Region: &opts.Region}, - })) - // create new session with current metadata creds - svcProfile := sts.New(sessProfile) - - // the params we'll need for assume-role - roleSessionName := generateRoleSessionName(svcProfile) - params := &sts.AssumeRoleInput{ - RoleArn: &acctCurrent.RoleArn, - RoleSessionName: &roleSessionName, - DurationSeconds: &opts.SessionDuration, - } - // now try the assume-role with the new metadata creds - resp, errr := svcProfile.AssumeRole(params) - if errr != nil { - return errr - } - - // Log the response data. Truncate for security - goslogger.Loggo.Info("Response from AssumeRole", "AccessKeyId", *resp.Credentials.AccessKeyId, - "AccountName", fmt.Sprintf("%s", acctCurrent.AccountName), - "RoleArn", fmt.Sprintf("%s", acctCurrent.RoleArn), - "Expiration", resp.Credentials.Expiration.String()) - errr = haveCredsWillWrite(resp.Credentials, opts, instanceProfileArn, acctCurrent) - return errr -} - -func createFile(filename string) error { - _, err := os.Create(filename) - if err != nil { - return err - } - return err -} - -// ReadExpire reads the passed in aws creds file and looks for a known -// expires token string. If it can't find anything it assumes credentials -// need to be regenerated. If it finds anything it passes the timestamp to -// determineExpired to see if the token is expired. Returns boolean true -// for expired or false for not expired and an error obj. -func ReadExpire(outfile string, renewThreshold float64) (expired bool, err error) { - goslogger.Loggo.Debug("entered function", "function", "readExpire") - // compile our dateFormat regex - filter := regexp.MustCompile(expiresToken + reDateFormat) - // see if outfile exists and exit func if not - fi, err := os.Open(outfile) - if err != nil { - err = createFile(outfile) - return true, err - } - // close fi on exit and check for its returned error - defer func() { - if err := fi.Close(); err != nil { - goslogger.Loggo.Warn(fmt.Sprintf("Error reading existing creds file, assumption is to create new: %s", err)) - } - }() - // make a read buffer - r := io.Reader(fi) - if err != nil { - return true, err - } - input := bufio.NewScanner(r) - goslogger.Loggo.Info("Scanning credentials file...") - for input.Scan() { - line := input.Text() - match := filter.FindAllString(line, -1) - if match != nil { - dateString := strings.Split(match[0], "@")[1] - goslogger.Loggo.Info("Detected expiration string", "TokenExpires", dateString) - expired := determineExpired(dateString, renewThreshold) - if expired { - return true, err - } - return false, err - } - } - return true, err -} - -// ModeDecider looks at the given input parameters and tries to decide -// the user's intention. Right now this is just deciding between -// using MFA or using instance-profile. -func ModeDecider(opts *RunnerOptions) (mode string) { - goslogger.Loggo.Debug("entered function", "function", "modeDecider") - var reModeMfa = regexp.MustCompile(`[0-9]{6}`) - var reModeProfile = regexp.MustCompile(`\w{1,}`) - goslogger.Loggo.Debug("modeDecider", "reModeMfa match?", reModeMfa.MatchString(opts.TokenCode)) - goslogger.Loggo.Debug("modeDecider", "reModeProfile match?", reModeProfile.MatchString(opts.Profile)) - // determine which mode we're going to run in - mode = "instance-profile" - switch { - case reModeMfa.MatchString(opts.TokenCode) && reModeProfile.MatchString(opts.Profile): - mode = "mfa" - case reModeProfile.MatchString(opts.Profile) && opts.SerialNumber == "" && opts.TokenCode == "": - mode = "profile-only" - default: - mode = "instance-profile" - } - goslogger.Loggo.Info("MODE", "determined mode", mode) - return mode -} - -// Account just holds the rolearn, region, and -// account name for each entry to build from -type Account struct { - RoleArn string - AccountName string - Region string - RoundRobin bool -} - -// RunnerOptions type provides easier arguments to the runner function -type RunnerOptions struct { - OutFile, RoleSessionName, Mode, Profile, SerialNumber, TokenCode, Region string - RenewThreshold, Seconds float64 - SessionDuration int64 - DaemonFlag, Force bool - Accounts []Account -} - -// DeleteCredFileEntries deletes all credentials -// loaded in to RunnerOptions.[]Account in the -// creds file. -func DeleteCredFileEntries(opts *RunnerOptions) error { - // build the acfmgr cred file session - credContents := []string{"blank"} - c, err := acfmgr.NewCredFileSession(opts.OutFile) - for _, acct := range opts.Accounts { - credName := "[" + acct.AccountName + "]" - goslogger.Loggo.Debug("purgecreds", "addingEntry", credName) - c.NewEntry(credName, credContents) - } - err = c.DeleteEntries() - if err != nil { - return err - } - return err -} - -// LoadArnsFile just returns a []string from a json -// config file -func LoadArnsFile(filename string) ([]Account, error) { - var rlist []Account - file, err := os.Open(filename) - if err != nil { - return rlist, err - } - decoder := json.NewDecoder(file) - config := configuration{} - err = decoder.Decode(&config) - if err != nil { - return rlist, err - } - return config.Roles, err -} diff --git a/gossamer/gossamer_test.go b/gossamer/gossamer_test.go deleted file mode 100644 index 617a2d6..0000000 --- a/gossamer/gossamer_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package gossamer - -import ( - "bytes" - "fmt" - "io/ioutil" - "os" - "reflect" - "strings" - "testing" - - "github.com/GESkunkworks/gossamer/goslogger" -) - -func writeBufferToFile(filename string, b *bytes.Buffer) error { - err := ioutil.WriteFile(filename, b.Bytes(), 0644) - return err -} - -func genTestArnsJSON() (*bytes.Buffer, error) { - seed := `{"Roles": [ - {"RoleArn": "arn:aws:iam::123456789101:role/prod-role", - "AccountName": "prod-account", - "Region": "us-east-1"}, - {"RoleArn": "arn:aws:iam::110987654321:role/dev-role", - "AccountName": "dev-account", - "Region": "us-west-2"}]}` - var b bytes.Buffer - _, err := b.WriteString(seed) - if err != nil { - fmt.Println("error: ", err) - return &b, err - } - return &b, err -} - -func deleteFile(filename string) { - err := os.Remove(filename) - if err != nil { - panic(err) - } -} - -func TestLoadArnsFile(t *testing.T) { - testRoleFileName := "gossamer_test_json.json" - b, err := genTestArnsJSON() - if err != nil { - t.Errorf("unable to seed test data: %s", err) - } - err = writeBufferToFile(testRoleFileName, b) - if err != nil { - t.Errorf("unable to write test file: %s", err) - } - accounts, err := LoadArnsFile(testRoleFileName) - if err != nil { - t.Errorf("Error loading file: %s", err) - } - if (len(accounts) < 2) || len(accounts) > 2 { - t.Errorf("Number of arns loaded incorrect, got: %d, want: %d.", len(accounts), 2) - } - for _, acct := range accounts { - if reflect.TypeOf(acct.AccountName).String() != "string" { - t.Errorf("AccountName not string") - } - if reflect.TypeOf(acct.RoleArn).String() != "string" { - t.Errorf("RoleArn not string") - } - if reflect.TypeOf(acct.Region).String() != "string" { - t.Errorf("Region not string") - } - } - defer deleteFile(testRoleFileName) -} - -func buildRunnerOpts() RunnerOptions { - // build out option defaults then modify - var renewThreshold, seconds, sessionDuration int64 - renewThreshold = 10 - seconds = 300 - sessionDuration = 3600 - var accts []Account - - opts := RunnerOptions{ - OutFile: "./gossamer_creds", - Accounts: accts, - RoleSessionName: "gossamer", - Profile: "", - SerialNumber: "", - TokenCode: "", - RenewThreshold: float64(renewThreshold), - Seconds: float64(seconds), - SessionDuration: sessionDuration, - DaemonFlag: false, - Mode: "instance-profile", - Region: "us-east-1", - Force: false} - return opts -} - -func TestModeDeciderMFA(t *testing.T) { - ropts := buildRunnerOpts() - ropts.Profile = "iam" - ropts.SerialNumber = "GADT000012345" - ropts.DaemonFlag = false - ropts.TokenCode = "123456" - acct := Account{RoleArn: "arn:aws:iam::123456789101:role/prod-role", - AccountName: "prod-account", - Region: "us-east-1"} - ropts.OutFile = "./gossamer_test_MFA.txt" - ropts.Accounts = append(ropts.Accounts, acct) - got := ModeDecider(&ropts) - want := "mfa" - if got != want { - t.Errorf("Mode detection failed. Wanted: '%s', Got: '%s'", want, got) - } -} - -func TestMFASerialNoValidation(t *testing.T) { - ropts := buildRunnerOpts() - ropts.Profile = "iam" - ropts.SerialNumber = "ABC000123FT" - ropts.DaemonFlag = false - ropts.TokenCode = "123456" - acct := Account{RoleArn: "arn:aws:iam::123456789101:role/prod-role", - AccountName: "prod-account", - Region: "us-east-1"} - ropts.OutFile = "./gossamer_test_MFA.txt" - ropts.Accounts = append(ropts.Accounts, acct) - got := ModeDecider(&ropts) - want := "mfa" - if got != want { - t.Errorf("Mode detection failed. Wanted: '%s', Got: '%s'", want, got) - } -} -func TestMFABadCreds(t *testing.T) { - ropts := buildRunnerOpts() - ropts.Profile = "iam" - ropts.SerialNumber = "GADT000012345" - ropts.DaemonFlag = false - ropts.TokenCode = "123456" - acct := Account{RoleArn: "arn:aws:iam::123456789101:role/prod-role", - AccountName: "prod-account", - Region: "us-east-1"} - ropts.OutFile = "./gossamer_test_MFA.txt" - err := generateTestCredFile(ropts.OutFile, "iam") - ropts.Accounts = append(ropts.Accounts, acct) - ropts.Mode = ModeDecider(&ropts) - err = GenerateNewMfa(&ropts, ropts.Accounts) - expectedErr := "InvalidClientTokenId: The security token included in the request is invalid" - if !strings.Contains(err.Error(), expectedErr) { - t.Errorf("Expected: '%s', Got: '%s'", expectedErr, err) - } -} - -func TestModeDeciderInstanceProfile(t *testing.T) { - ropts := buildRunnerOpts() - ropts.Accounts = []Account{} - ropts.OutFile = "./gossamer_test_instance_profile.txt" - acct := Account{RoleArn: "arn:aws:iam::123456789101:role/prod-role", - AccountName: "prod-account", - Region: ropts.Region} - ropts.Accounts = append(ropts.Accounts, acct) - ropts.DaemonFlag = false - got := ModeDecider(&ropts) - want := "instance-profile" - if got != want { - t.Errorf("Mode detection failed. Wanted: '%s', Got: '%s'", want, got) - } -} - -func TestModeDeciderProfileOnly(t *testing.T) { - ropts := buildRunnerOpts() - ropts.Profile = "saml" - ropts.Accounts = []Account{} - ropts.OutFile = "./gossamer_test_mode_decider.txt" - acct := Account{RoleArn: "arn:aws:iam::123456789101:role/prod-role", - AccountName: "prod-account", - Region: ropts.Region} - ropts.Accounts = append(ropts.Accounts, acct) - ropts.DaemonFlag = false - got := ModeDecider(&ropts) - want := "profile-only" - if got != want { - t.Errorf("Mode detection failed. Wanted: '%s', Got: '%s'", want, got) - } -} - -func TestModeDeciderMfaNoAssume(t *testing.T) { - ropts := buildRunnerOpts() - ropts.Profile = "saml" - ropts.Accounts = []Account{} - ropts.OutFile = "./gossamer_test_mode_decider.txt" - ropts.DaemonFlag = false - ropts.Mode = "mfa_noassume" - got := ModeDecider(&ropts) - want := "profile-only" - if got != want { - t.Errorf("Mode detection failed. Wanted: '%s', Got: '%s'", want, got) - } -} - -func generateTestExpireFile(filename string, expires string) error { - var b bytes.Buffer - _, err := b.WriteString(expires) - if err != nil { - fmt.Println(err) - } - err = writeBufferToFile(filename, &b) - return err -} - -func generateTestCredFile(filename string, entryName string) error { - var b bytes.Buffer - _, err := b.WriteString("[" + entryName + "]\n") - _, err = b.WriteString("output = json\n") - _, err = b.WriteString("region = us-east-1\n") - _, err = b.WriteString("aws_access_key_id = ASIAJASDFEFOIFJCAFXAQ\n") - _, err = b.WriteString("aws_secret_access_key = uHOawoeifaowinafoiawi/eoia asdf/14bocE1pNtd4\n") - _, err = b.WriteString("aws_session_token = FQoDYXdzEN///////////wEaDBBebVjaMasRQbNcYCKvAZfpQw5TGWUSydHYx5rrMx1royMnMJx+ZK781kiFbifoAh1p5DXWOeY1xrMX93iw3uDEOPMvN5lTNWACOsRqXSgCkbHY/HYD13NnZjQUZ/bGQJMbFxpQ6Z+LuaL5nJY0oUc54NPRTVZTUqTu1ePnnJopYr/+9V7elY+KP0DSNDFWtXg4Z6/OjJPJoSKE8SYN3KgpVJ2gVUC6xfjEtzT7PcvhY+H1j2iTKNdICoD4KjMo5feXyQU=\n") - if err != nil { - fmt.Println(err) - } - err = writeBufferToFile(filename, &b) - return err -} - -func TestReadExpireNoFile(t *testing.T) { - // build opts - ropts := buildRunnerOpts() - ropts.Accounts = []Account{} - ropts.OutFile = "./gossamer_test_expire_file.txt" - acct := Account{RoleArn: "arn:aws:iam::123456789101:role/prod-role", - AccountName: "prod-account", - Region: ropts.Region} - ropts.Accounts = append(ropts.Accounts, acct) - ropts.DaemonFlag = false - // now build expires file - got, err := ReadExpire(ropts.OutFile, ropts.RenewThreshold) - if err != nil { - t.Errorf("Got err in test read expire: '%s'", err) - } - want := true - if got != want { - t.Errorf("ReadExpire failed. Wanted: '%t', Got: '%t'", got, want) - } - defer deleteFile(ropts.OutFile) -} - -func TestReadExpire(t *testing.T) { - // build opts - ropts := buildRunnerOpts() - ropts.Accounts = []Account{} - ropts.OutFile = "./gossamer_test_expire_file.txt" - acct := Account{RoleArn: "arn:aws:iam::123456789101:role/prod-role", - AccountName: "prod-account", - Region: ropts.Region} - ropts.Accounts = append(ropts.Accounts, acct) - ropts.DaemonFlag = false - // now build expires file - expires := expiresToken + "2017-05-17 23:12:25 +0000 UTC" - err := generateTestExpireFile(ropts.OutFile, expires) - got, err := ReadExpire(ropts.OutFile, ropts.RenewThreshold) - if err != nil { - t.Errorf("Got err in test read expire: '%s'", err) - } - want := true - if got != want { - t.Errorf("ReadExpire failed. Wanted: '%t', Got: '%t'", got, want) - } - defer deleteFile(ropts.OutFile) -} - -func TestMain(m *testing.M) { - // set up global logging for running tests - daemonFlag := false - logFile := "./gossamer_tests_log.json" - loglevel := "info" - goslogger.SetLogger(daemonFlag, logFile, loglevel) - retCode := m.Run() - os.Exit(retCode) -} diff --git a/gossamer/gossamer_test_MFA.txt b/gossamer/gossamer_test_MFA.txt deleted file mode 100644 index 1ee1a2e..0000000 --- a/gossamer/gossamer_test_MFA.txt +++ /dev/null @@ -1,6 +0,0 @@ -[iam] -output = json -region = us-east-1 -aws_access_key_id = ASIAJASDFEFOIFJCAFXAQ -aws_secret_access_key = uHOawoeifaowinafoiawi/eoia asdf/14bocE1pNtd4 -aws_session_token = FQoDYXdzEN///////////wEaDBBebVjaMasRQbNcYCKvAZfpQw5TGWUSydHYx5rrMx1royMnMJx+ZK781kiFbifoAh1p5DXWOeY1xrMX93iw3uDEOPMvN5lTNWACOsRqXSgCkbHY/HYD13NnZjQUZ/bGQJMbFxpQ6Z+LuaL5nJY0oUc54NPRTVZTUqTu1ePnnJopYr/+9V7elY+KP0DSNDFWtXg4Z6/OjJPJoSKE8SYN3KgpVJ2gVUC6xfjEtzT7PcvhY+H1j2iTKNdICoD4KjMo5feXyQU= diff --git a/gossamer/gossamer_tests_log.json b/gossamer/gossamer_tests_log.json deleted file mode 100644 index ae21fee..0000000 --- a/gossamer/gossamer_tests_log.json +++ /dev/null @@ -1,80 +0,0 @@ -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-22T10:31:00.097758687-04:00"} -{"determined mode":"instance-profile","lvl":"info","msg":"MODE","t":"2017-05-22T10:31:00.098024228-04:00"} -{"determined mode":"profile-only","lvl":"info","msg":"MODE","t":"2017-05-22T10:31:00.098110372-04:00"} -{"lvl":"info","msg":"Scanning credentials file...","t":"2017-05-22T10:31:00.101501969-04:00"} -{"TokenExpires":"2017-05-17 23:12:25 +0000 UTC","lvl":"info","msg":"Detected expiration string","t":"2017-05-22T10:31:00.10162477-04:00"} -{"ExpiresIn":-6678.5850277698,"lvl":"info","msg":"Token expiration check","renewThreshold":10,"t":"2017-05-22T10:31:00.101666739-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-22T10:56:36.166440354-04:00"} -{"determined mode":"instance-profile","lvl":"info","msg":"MODE","t":"2017-05-22T10:56:36.167406318-04:00"} -{"determined mode":"profile-only","lvl":"info","msg":"MODE","t":"2017-05-22T10:56:36.167497933-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-22T10:59:03.002132778-04:00"} -{"determined mode":"instance-profile","lvl":"info","msg":"MODE","t":"2017-05-22T10:59:03.002435311-04:00"} -{"determined mode":"profile-only","lvl":"info","msg":"MODE","t":"2017-05-22T10:59:03.002539731-04:00"} -{"lvl":"info","msg":"Scanning credentials file...","t":"2017-05-22T10:59:03.006644186-04:00"} -{"TokenExpires":"2017-05-17 23:12:25 +0000 UTC","lvl":"info","msg":"Detected expiration string","t":"2017-05-22T10:59:03.00700855-04:00"} -{"ExpiresIn":-6706.6334511356,"lvl":"info","msg":"Token expiration check","renewThreshold":10,"t":"2017-05-22T10:59:03.007068712-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-24T17:11:00.64599615-04:00"} -{"determined mode":"instance-profile","lvl":"info","msg":"MODE","t":"2017-05-24T17:11:00.646495183-04:00"} -{"determined mode":"profile-only","lvl":"info","msg":"MODE","t":"2017-05-24T17:11:00.646592494-04:00"} -{"lvl":"info","msg":"Scanning credentials file...","t":"2017-05-24T17:11:00.650676196-04:00"} -{"TokenExpires":"2017-05-17 23:12:25 +0000 UTC","lvl":"info","msg":"Detected expiration string","t":"2017-05-24T17:11:00.650758547-04:00"} -{"ExpiresIn":-9958.594179889133,"lvl":"info","msg":"Token expiration check","renewThreshold":10,"t":"2017-05-24T17:11:00.650793911-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-24T17:12:40.789939427-04:00"} -{"determined mode":"instance-profile","lvl":"info","msg":"MODE","t":"2017-05-24T17:12:40.790229706-04:00"} -{"determined mode":"profile-only","lvl":"info","msg":"MODE","t":"2017-05-24T17:12:40.790315626-04:00"} -{"lvl":"info","msg":"Scanning credentials file...","t":"2017-05-24T17:12:40.794150323-04:00"} -{"TokenExpires":"2017-05-17 23:12:25 +0000 UTC","lvl":"info","msg":"Detected expiration string","t":"2017-05-24T17:12:40.794220591-04:00"} -{"ExpiresIn":-9960.263237575733,"lvl":"info","msg":"Token expiration check","renewThreshold":10,"t":"2017-05-24T17:12:40.794255114-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-24T17:14:01.959343279-04:00"} -{"determined mode":"instance-profile","lvl":"info","msg":"MODE","t":"2017-05-24T17:14:01.959620752-04:00"} -{"determined mode":"profile-only","lvl":"info","msg":"MODE","t":"2017-05-24T17:14:01.959703211-04:00"} -{"determined mode":"profile-only","lvl":"info","msg":"MODE","t":"2017-05-24T17:14:01.959796815-04:00"} -{"lvl":"info","msg":"Scanning credentials file...","t":"2017-05-24T17:14:01.964869315-04:00"} -{"TokenExpires":"2017-05-17 23:12:25 +0000 UTC","lvl":"info","msg":"Detected expiration string","t":"2017-05-24T17:14:01.964958764-04:00"} -{"ExpiresIn":-9961.616083155184,"lvl":"info","msg":"Token expiration check","renewThreshold":10,"t":"2017-05-24T17:14:01.964989874-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-24T17:31:58.159085748-04:00"} -{"determined mode":"instance-profile","lvl":"info","msg":"MODE","t":"2017-05-24T17:31:58.159435503-04:00"} -{"determined mode":"profile-only","lvl":"info","msg":"MODE","t":"2017-05-24T17:31:58.159530704-04:00"} -{"determined mode":"profile-only","lvl":"info","msg":"MODE","t":"2017-05-24T17:31:58.15962994-04:00"} -{"lvl":"info","msg":"Scanning credentials file...","t":"2017-05-24T17:31:58.163539866-04:00"} -{"TokenExpires":"2017-05-17 23:12:25 +0000 UTC","lvl":"info","msg":"Detected expiration string","t":"2017-05-24T17:31:58.163618937-04:00"} -{"ExpiresIn":-9979.552727517983,"lvl":"info","msg":"Token expiration check","renewThreshold":10,"t":"2017-05-24T17:31:58.163651671-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-24T22:48:39.971391586-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-24T22:50:07.718243807-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-24T22:51:02.688137296-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-24T22:51:41.82578495-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-24T22:51:52.217481938-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-24T22:57:53.104560906-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-24T22:58:03.99291528-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-24T22:58:29.595652452-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-24T22:59:40.606617286-04:00"} -{"error":"ExpiredToken: The security token included in the request is expired\n\tstatus code: 403, request id: 3256eef5-40f6-11e7-a86f-c77ae8d052d8","lvl":"crit","msg":"Error in getSessionToken","t":"2017-05-24T22:59:41.560898832-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-24T23:00:46.190757726-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-24T23:00:46.194289099-04:00"} -{"error":"ExpiredToken: The security token included in the request is expired\n\tstatus code: 403, request id: 5977deff-40f6-11e7-99d3-19359e6c3256","lvl":"crit","msg":"Error in getSessionToken","t":"2017-05-24T23:00:47.216486448-04:00"} -{"determined mode":"instance-profile","lvl":"info","msg":"MODE","t":"2017-05-24T23:00:47.216838969-04:00"} -{"determined mode":"profile-only","lvl":"info","msg":"MODE","t":"2017-05-24T23:00:47.217029192-04:00"} -{"determined mode":"profile-only","lvl":"info","msg":"MODE","t":"2017-05-24T23:00:47.217196717-04:00"} -{"lvl":"info","msg":"Scanning credentials file...","t":"2017-05-24T23:00:47.223034947-04:00"} -{"TokenExpires":"2017-05-17 23:12:25 +0000 UTC","lvl":"info","msg":"Detected expiration string","t":"2017-05-24T23:00:47.224695589-04:00"} -{"ExpiresIn":-10308.370413223467,"lvl":"info","msg":"Token expiration check","renewThreshold":10,"t":"2017-05-24T23:00:47.224794403-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-24T23:01:12.701814656-04:00"} -{"error":"ExpiredToken: The security token included in the request is expired\n\tstatus code: 403, request id: 694d375e-40f6-11e7-9ee4-9b290c80cbfd","lvl":"crit","msg":"Error in getSessionToken","t":"2017-05-24T23:01:13.774012641-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-30T16:01:20.161387907-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-30T16:01:20.17306559-04:00"} -{"error":"ExpiredToken: The security token included in the request is expired\n\tstatus code: 403, request id: c4270875-4572-11e7-8948-1986fdff2c97","lvl":"crit","msg":"Error in getSessionToken","t":"2017-05-30T16:01:27.373300842-04:00"} -{"determined mode":"instance-profile","lvl":"info","msg":"MODE","t":"2017-05-30T16:01:27.373672113-04:00"} -{"determined mode":"profile-only","lvl":"info","msg":"MODE","t":"2017-05-30T16:01:27.373822585-04:00"} -{"determined mode":"profile-only","lvl":"info","msg":"MODE","t":"2017-05-30T16:01:27.373943186-04:00"} -{"lvl":"info","msg":"Scanning credentials file...","t":"2017-05-30T16:01:27.379457468-04:00"} -{"TokenExpires":"2017-05-17 23:12:25 +0000 UTC","lvl":"info","msg":"Detected expiration string","t":"2017-05-30T16:01:27.379543528-04:00"} -{"ExpiresIn":-18529.03965975845,"lvl":"info","msg":"Token expiration check","renewThreshold":10,"t":"2017-05-30T16:01:27.379586738-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-30T16:04:58.336760672-04:00"} -{"determined mode":"mfa","lvl":"info","msg":"MODE","t":"2017-05-30T16:04:58.340831397-04:00"} -{"error":"ExpiredToken: The security token included in the request is expired\n\tstatus code: 403, request id: 42850a24-4573-11e7-805d-714421f5cfd4","lvl":"crit","msg":"Error in getSessionToken","t":"2017-05-30T16:04:59.404583399-04:00"} -{"determined mode":"instance-profile","lvl":"info","msg":"MODE","t":"2017-05-30T16:04:59.404905276-04:00"} -{"determined mode":"profile-only","lvl":"info","msg":"MODE","t":"2017-05-30T16:04:59.405088409-04:00"} -{"determined mode":"profile-only","lvl":"info","msg":"MODE","t":"2017-05-30T16:04:59.405237971-04:00"} -{"lvl":"info","msg":"Scanning credentials file...","t":"2017-05-30T16:04:59.410903878-04:00"} -{"TokenExpires":"2017-05-17 23:12:25 +0000 UTC","lvl":"info","msg":"Detected expiration string","t":"2017-05-30T16:04:59.412608459-04:00"} -{"ExpiresIn":-18532.573545145915,"lvl":"info","msg":"Token expiration check","renewThreshold":10,"t":"2017-05-30T16:04:59.41270974-04:00"} diff --git a/gossamer/saml.go b/gossamer/saml.go new file mode 100644 index 0000000..ea79483 --- /dev/null +++ b/gossamer/saml.go @@ -0,0 +1,324 @@ +package gossamer + +import ( + "encoding/base64" + "encoding/xml" + "encoding/json" + "strings" + "net/http" + "net/url" + "io" + "net/http/cookiejar" + "golang.org/x/net/publicsuffix" + "golang.org/x/net/html" + "bytes" + "errors" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/GESkunkworks/gossamer/goslogger" + "fmt" + "regexp" +) + +// XMLSAMLResponse is the top level struct for holding and unmarshaling the XML +// SAML assertion that comes back from the HTTP call +type XMLSAMLResponse struct { + XMLName xml.Name `xml:"Response"` + Assertion XMLSAMLAssertion `xml:"Assertion"` +} +// XMLSAMLAssertion is required for holding and unmarshaling the XML SAML +// assertion that comes back from the HTTP call. +type XMLSAMLAssertion struct { + Issuer string `xml:"Issuer"` + AttributeStatement XMLSAMLAttributes `xml:"AttributeStatement"` +} + +// XMLSAMLAttributes is required for holding and unmarshaling the XML SAML +// assertion that comes back from the HTTP call. +type XMLSAMLAttributes struct { + AttributeValues []XMLSAMLAttribute `xml:"Attribute"` +} + +// XMLSAMLAttribute is required for holding and unmarshaling the XML SAML +// assertion that comes back from the HTTP call. +type XMLSAMLAttribute struct { + Name string `xml:"Name,attr"` + AttributeValues []string `xml:"AttributeValue"` +} + +func (sc *SAMLSessionConfig) decodeAssertion() (err error) { + var parking []byte + parking, err = base64.StdEncoding.DecodeString(*sc.Assertion) + if err != nil { + goslogger.Loggo.Error("error decoding base64 SAML assertion", "error", err) + return err + } + assertionLength := len(parking) + goslogger.Loggo.Debug("got bas64 decoded assertion", "bytes", assertionLength) + if assertionLength < 1 { + err = errors.New("got SAML assertion of length zero please check url/target settings and check with SAML provider") + return err + } + var r XMLSAMLResponse + err = xml.Unmarshal(parking, &r) + if err != nil { + goslogger.Loggo.Error("error unmarshaling SAML assertion to xml struct") + return err + } + var roles []*SAMLRole + for _, val := range(r.Assertion.AttributeStatement.AttributeValues) { + if val.Name == "https://aws.amazon.com/SAML/Attributes/Role" { + for _, v := range(val.AttributeValues) { + role, err := newRoleFromAttributeValue(v) + if err != nil { + return err + } + roles = append(roles, role) + } + } + if val.Name == "https://aws.amazon.com/SAML/Attributes/RoleSessionName" { + if len(val.AttributeValues) > 0 { + sc.RoleSessionName = &val.AttributeValues[0] + } + } + if val.Name == "https://aws.amazon.com/SAML/Attributes/SessionDuration" { + if len(val.AttributeValues) > 0 { + sc.SessionDuration = &val.AttributeValues[0] + } + } + } + sc.Roles = roles + return err +} + +type SAMLSessionConfig struct { + SessionName *string + Roles []*SAMLRole `json:"Roles"` + Assertion *string `json:"Assertion"` + SamlUser *string + samlPass *string + SamlUrl *string + SamlTarget *string + RoleSessionName *string `json:"RoleSessionName"` + SessionDuration *string `json:"SessionDuration"` +} + +type SAMLRole struct { + AccountNumber string `json:"AccountNumber"` + RoleName string + RoleArn string + PrincipalArn string + Result *sts.AssumeRoleWithSAMLOutput + Identifier string +} + +// dump returns a formatted string of the current SAMLSessionConfig struct +// which is useful for displaying configuration to the user and debugging. +func (sc *SAMLSessionConfig) dump() (string) { + tempo := []byte{} + tempo, _ = json.Marshal(sc) + return string(tempo) +} + +// newSAMLSessionConfig returns a SAMLSessionConfig struct whose methods can be +// called to start a SAML session via HTTP and also assume roles that come back +// from the session's assertion +func newSAMLSessionConfig(sessionname, samluser, samlpass, samlurl, samltarget string) (SAMLSessionConfig) { + var sc SAMLSessionConfig + sc.SessionName = &sessionname + sc.SamlUser = &samluser + sc.samlPass = &samlpass + sc.SamlUrl = &samlurl + sc.SamlTarget = &samltarget + return sc +} + +// startSAMLSession attempts to make the HTTP calls required for obtaining +// the SAML assertion and use the response to decode a list of roles +// that could be assumed using the assertion +func (sc *SAMLSessionConfig) startSAMLSession() (err error) { + err = sc.getAssertion() + if err != nil { + goslogger.Loggo.Debug("error getting SAML assertion", "error", err) + return err + } + err = sc.decodeAssertion() + if err != nil { + // see if we can make a better error message for known errors + if strings.Contains(err.Error(), "illegal base64") { + message := fmt.Sprintf("error in decoding SAML assertion make sure password for user '%s' is correct", *sc.SamlUser) + err = errors.New(message) + } + goslogger.Loggo.Error("error attempting to decode SAML assertion", "error", err) + } + return err +} + +func getRoleUniqueId(roleArn string) (uid *string, err error) { + rolename, accountnumber, err := parseRoleArn(roleArn) + if err != nil { + return uid, err + } + uidTemp := fmt.Sprintf("%s_%s", *accountnumber, *rolename) + return &uidTemp, err +} + +func parseRoleArn(roleArn string) (rolename, accountnumber *string, err error) { + chunks := strings.Split(roleArn, ":") + if len(chunks) < 6 { + err = errors.New("error parsing role and accountnumber from roleArn during colon split") + return rolename, accountnumber, err + } + accountnumber = &chunks[4] + accountnumberRegex := regexp.MustCompile("[0-9]{12}") + if !accountnumberRegex.MatchString(*accountnumber) { + err = errors.New("string from expected location in arn does not match account number regex") + return rolename, accountnumber, err + } + roletemp := strings.Split(chunks[5], "/") + roletemp2 := strings.Join(roletemp[1:], "/") + rolename = &roletemp2 + return rolename, accountnumber, err +} + +func newRoleFromAttributeValue(raw string) (*SAMLRole, error) { + role := SAMLRole{} + var err error + parn := strings.Split(raw, ",") + if len(parn) != 2 { + err = errors.New("Error parsing PrincipalArn from saml:AttributeValue during comma split") + return &role, err + } else { + role.PrincipalArn = parn[1] + } + + role.RoleArn = parn[0] + rolename, accountnumber, err := parseRoleArn(role.RoleArn) + if err != nil { + return &role, err + } + role.RoleName = *rolename + role.AccountNumber = *accountnumber + idTemp, err := getRoleUniqueId(role.RoleArn) + if err != nil { + return &role, err + } + role.Identifier = *idTemp + return &role, err +} + +func (sc *SAMLSessionConfig) getAssertion() (err error) { + var samlassertion string + // set up cookie jar + jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + if err != nil { + return err + } + client := &http.Client{ + Jar: jar, + } + data := url.Values{} + data.Set("username", *sc.SamlUser) + data.Set("password", *sc.samlPass) + data.Set("target", *sc.SamlTarget) + req, err := http.NewRequest("POST", *sc.SamlUrl, bytes.NewBufferString(data.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value") + if err != nil { + return err + } + + resp, err := client.Do(req) + if err != nil { + goslogger.Loggo.Debug("error in SAML client.Do", "error", err) + return err + } + defer resp.Body.Close() + z := html.NewTokenizer(resp.Body) + count := 0 + done := false + for { + count++ + if done { + break + } + tt := z.Next() + tn, _ := z.TagName() + switch { + case tt == html.ErrorToken: + err := z.Err() + if err == io.EOF { + done = true + } else { + done = true + return err + } + break + case string(tn) == "input": + for { + k, v, more := z.TagAttr() + if string(k) == "value" { + samlassertion = string(v) + } + if !more { + break + } + } + } + } + sc.Assertion = &samlassertion + return err +} + +// assumeSAMLRoles uses the previously obtained assertion to attempt to either assume +// all possible roles in the assertion as indicated by the allRoles input boolean +// or simply assume a preset list of roleArns as passed in via the []string +// it returns a slice of gossamer.Mapping structs which can hold more metadata than the +// SAMLRoles that have been built thus far +func (sc *SAMLSessionConfig) assumeSAMLRoles(allRoles bool, roleArns []string) (mappings []*Mapping, err error) { + client := sts.New(session.New()) + count_success := 0 + count_fail := 0 + for _, role := range(sc.Roles) { + var m Mapping + if !allRoles && !contains(roleArns, role.RoleArn) { + goslogger.Loggo.Debug("Skipping role assumption per configuration directives", "role.RoleArn", role.RoleArn) + continue + } + input := sts.AssumeRoleWithSAMLInput{ + PrincipalArn: &role.PrincipalArn, + RoleArn: &role.RoleArn, + SAMLAssertion: sc.Assertion, + } + result, err := client.AssumeRoleWithSAML(&input) + if err != nil { + count_fail++ + goslogger.Loggo.Info("Error assuming role", "FlowName", *sc.SessionName, "Error", err.Error(), "RoleName", role.RoleName, "RoleArn", role.RoleArn ) + } else { + role.Result = result + m.RoleArn = role.RoleArn + m.ProfileName = role.Identifier + m.credential = result.Credentials + mappings = append(mappings, &m) + count_success++ + goslogger.Loggo.Info("Successfully assumed role", "FlowName", *sc.SessionName, "Identifier", role.Identifier, "RoleArn", role.RoleArn, "AccessKeyId", *role.Result.Credentials.AccessKeyId) + } + } + goslogger.Loggo.Info("Finished attempt at assuming roles in SAML Assertion", "successes", count_success, "failures", count_fail) + return mappings, err +} + + +func contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + + + diff --git a/gossamer/sampleGenerators.go b/gossamer/sampleGenerators.go new file mode 100644 index 0000000..86f91b8 --- /dev/null +++ b/gossamer/sampleGenerators.go @@ -0,0 +1,100 @@ +package gossamer + +func newSamplePermMFA() (*PermCredsConfig) { + pcc := PermCredsConfig{} + mfa := MFA{} + pcc.MFA = &mfa + serial := CParam{} + pcc.MFA.Serial = &serial + pcc.MFA.Serial.name = "Serial" + pcc.MFA.Serial.Source = "config" + pcc.MFA.Serial.Value = "sampleserial" + + token := CParam{} + pcc.MFA.Token = &token + pcc.MFA.Token.name = "Token" + pcc.MFA.Token.Source = "config" + pcc.MFA.Token.Value = "sampletoken" + return &pcc +} + +func newSampleAssumptionsPrimary() (*Assumptions) { + a := Assumptions{} + m1 := newSampleMappingPrimary() + m2 := newSampleMappingPrimary() + m2.RoleArn = "arn:aws:iam::123456789012:role/role2" + m2.ProfileName = "role2" + m2.Region = "" + m2.NoOutput = false + a.Mappings = append(a.Mappings, *m1) + a.Mappings = append(a.Mappings, *m2) + return &a +} + +func newSampleAssumptionsSecondary() (*Assumptions) { + a := Assumptions{} + m1 := newSampleMappingSecondary() + a.Mappings = append(a.Mappings, *m1) + return &a +} + +func newSampleMappingPrimary() (*Mapping) { + m := Mapping{} + m.RoleArn = "arn:aws:iam::123456789012:role/sub-admin" + m.ProfileName = "sub-admin" + m.Region = "us-west-2" + m.NoOutput = true + return &m +} + +func newSampleMappingSecondary() (*Mapping) { + m := Mapping{} + m.RoleArn = "arn:aws:iam::123456789012:role/admin" + m.ProfileName = "admin" + m.Region = "us-west-2" + m.NoOutput = false + m.SponsorCredsArn = "arn:aws:iam::123456789012:role/sub-admin" + return &m +} + +func newSampleSAMLConfig() (*SAMLConfig) { + sc := SAMLConfig{} + u := CParam{Source: "env", Value: "SAML_USER"} + p := CParam{Source: "prompt"} + url := CParam{Source: "config", Value: "https://my.saml.auth.url.com/auth.fcc"} + t := CParam{Source: "config", Value: "https://my.auth.target.com/fss/idp/startSSO.ping?PartnerSpId=urn:amazon:webservices" } + sc.Username = &u + sc.Password = &p + sc.URL = &url + sc.Target = &t + return &sc +} + +// GenerateConfigSkeleton sets up a sample Config object and +// with a bunch of sample values set and returns it +func GenerateConfigSkeleton() (*Config) { + gc := Config{} + gc.OutFile = "./path/to/credentials/file" + flow1 := Flow{ + Name: "sample-permanent-creds-mfa", + AllowFailure: true, + PermCredsConfig: newSamplePermMFA(), + PAss: newSampleAssumptionsPrimary(), + } + gc.Flows = append(gc.Flows, &flow1) + + flow2 := Flow{ + Name: "sample-saml", + AllowFailure: false, + Region: "us-east-2", + SAMLConfig: newSampleSAMLConfig(), + PAss: newSampleAssumptionsPrimary(), + SAss: newSampleAssumptionsSecondary(), + DoNotPropagateRegion: true, + } + flow2.PAss.AllRoles = true + gc.Flows = append(gc.Flows, &flow2) + return &gc +} + + diff --git a/gossamer/utils.go b/gossamer/utils.go new file mode 100644 index 0000000..44204cf --- /dev/null +++ b/gossamer/utils.go @@ -0,0 +1,134 @@ +package gossamer + +import ( + "encoding/json" + "errors" + "os" + "io/ioutil" + "github.com/GESkunkworks/gossamer/goslogger" + "gopkg.in/yaml.v2" + +) + +// LegacyAccount just holds the rolearn, region, and +// account name for each entry to build from +type LegacyAccount struct { + RoleArn string + AccountName string + Region string +} + +// legacyConfiguration holds a list of +// legacy Account structs +type legacyConfiguration struct { + Roles []LegacyAccount +} + +// loadLegacyRolesFile returns a slice of Account structs loaded +// from a legacy gossamer roles file +func loadLegacyRolesFile(filename string) ([]LegacyAccount, error) { + config := legacyConfiguration{} + file, err := os.Open(filename) + if err != nil { + return config.Roles, err + } + decoder := json.NewDecoder(file) + err = decoder.Decode(&config) + if err != nil { + return config.Roles, err + } + goslogger.Loggo.Debug("done loading rolesfile", "num_roles", len(config.Roles)) + return config.Roles, err +} + +func convertAcctsToMappings(accounts []LegacyAccount) (mappings []Mapping) { + for _, account := range(accounts) { + m := Mapping{ + RoleArn: account.RoleArn, + ProfileName: account.AccountName, + Region: account.Region, + } + mappings = append(mappings, m) + } + goslogger.Loggo.Debug("converted roles to mappings", "num_roles", len(accounts), "num_mappings", len(mappings)) + return mappings +} + +func convertLegacyRolesToMappings(filename string) (mappings []Mapping, err error) { + accounts, err := loadLegacyRolesFile(filename) + mappings = convertAcctsToMappings(accounts) + if err != nil { return mappings, err } + return mappings, err +} + +func (gc *Config) ConvertLegacyFlagsToConfig(gfl *GossFlags) (err error) { + goslogger.Loggo.Debug("Legacy: starting ConvertLegacyFlagsToConfig") + var mappings []Mapping + var accounts []LegacyAccount + gc.OutFile = gfl.OutFile + flow := Flow{Name: "gossamer-legacy"} + if gfl.RolesFile != "" { + goslogger.Loggo.Debug("Legacy: attempting to convert legacy roles roles file to mappings") + mappings, err = convertLegacyRolesToMappings(gfl.RolesFile) + if err != nil { + return(err) + } + } + if gfl.Region != "" { + flow.Region = gfl.Region + } + if gfl.RoleArn == "" && gfl.RolesFile == "" { + err = errors.New("Legacy: must specify role ARN with '-a' or '-rolesfile'. Exiting.") + return err + } + if gfl.RoleArn != "" && gfl.RolesFile == "" { + // just building one account struct + acct := LegacyAccount{RoleArn: gfl.RoleArn, AccountName: gfl.ProfileEntryName, Region: gfl.Region} + accounts = append(accounts, acct) + mappings = convertAcctsToMappings(accounts) + } + if len(mappings) == 0 { + err = errors.New("must specify role ARN with '-a' or '-rolesfile'. Exiting.") + return err + } + if 900 > gfl.SessionDuration { + err = errors.New("sessionDuration is outside threshold min=900") + return err + } + // now we have enough to build our flow hopefully + goslogger.Loggo.Debug("Legacy: done attempting to getting mappings", "len(mappings)", len(mappings)) + if len(mappings) > 0 { + as := Assumptions{} + flow.PAss = &as + flow.PAss.Mappings = mappings + pcc := PermCredsConfig{} + flow.PermCredsConfig = &pcc + if len(gfl.SerialNumber) > 0 && len(gfl.TokenCode) > 0 { + flow.PermCredsConfig = newSamplePermMFA() + flow.PermCredsConfig.MFA.Serial.Value = gfl.SerialNumber + flow.PermCredsConfig.MFA.Token.Value = gfl.TokenCode + } + if len(gfl.Profile) > 0 { + flow.PermCredsConfig.ProfileName = gfl.Profile + } + } + gc.Flows = append(gc.Flows, &flow) + if gfl.GeneratedConfigOutputFile != "" && gfl.GeneratedConfigOutputFile != "@sample" { + goslogger.Loggo.Info("attempting to generate config file from translated arguments") + err = WriteConfigToFile(gc, gfl.GeneratedConfigOutputFile) + if err != nil { return err } + } + goslogger.Loggo.Info("wrote configuration to file", "filename", gfl.GeneratedConfigOutputFile) + return err +} + + +// WriteConfigToFile takes a Config object and writes it to the desired +// filename +func WriteConfigToFile(gc *Config, filename string) (err error) { + dataBytes, err := yaml.Marshal(gc) + if err != nil { return err } + err = ioutil.WriteFile(filename, dataBytes, 0644) + return err +} + diff --git a/legacy_build/Jenkinsfile b/legacy_build/Jenkinsfile deleted file mode 100644 index 1e57cc2..0000000 --- a/legacy_build/Jenkinsfile +++ /dev/null @@ -1,49 +0,0 @@ -// pipeline script to build gossamer and push build to s3 bucket -// depends on credentials being set up in Jenkins ahead of time -// and docker to be installed on agent - -def majVersion = '1' -def minVersion = '2' -def relVersion = '2' - -def version = "${majVersion}.${minVersion}.${relVersion}.${env.BUILD_NUMBER}" -def packageNameNix = "gossamer-linux-amd64-${version}.tar.gz" -def packageNameNixLatest = "gossamer-linux-amd64-latest.tar.gz" -def packageNameMac = "gossamer-darwin-amd64-${version}.tar.gz" -def packageNameMacLatest = "gossamer-darwin-amd64-latest.tar.gz" -def packageNameWindows = "gossamer-windows-amd64-${version}.tar.gz" -def packageNameWindowsLatest = "gossamer-windows-amd64-latest.tar.gz" -def bucketPath = "builds/" - -try { - node ("master"){ - withCredentials([string(credentialsId: 'cloudpod-slack-token', variable: 'SLACKTOKEN'), - string(credentialsId: 'cloudpod-slack-org', variable: 'SLACKORG'), - string(credentialsId: 'gossamer-builds-s3-bucket', variable: 'S3BUCKET')]) - { - stage('cleanup') { - deleteDir() - } - stage ('checkout source') { - checkout scm - } - stage ('build build docker') { - sh "docker build . -t gossbuilder" - } - stage ('run build docker') { - sh "docker run gossbuilder ./build.sh ${version} ${packageNameNix} ${packageNameMac} ${packageNameWindows} ${packageNameNixLatest} ${packageNameMacLatest} ${packageNameWindowsLatest} ${S3BUCKET} ${bucketPath}" - } - stage ('notify') { - slackSend channel: '#cloudpod-feed', color: 'good', message: "gossamer build SUCCESS. Mac package: https://s3.amazonaws.com/${S3BUCKET}/${bucketPath}${packageNameMacLatest}, Nix package: https://s3.amazonaws.com/${S3BUCKET}/${bucketPath}${packageNameNixLatest}", teamDomain: "${SLACKORG}", token:"${SLACKTOKEN}" - } - } - } -} catch (error) { - withCredentials([string(credentialsId: 'cloudpod-slack-token', variable: 'SLACKTOKEN'), - string(credentialsId: 'cloudpod-slack-org', variable: 'SLACKORG')]) - { - stage ('notify failure') { - slackSend channel: '#cloudpod-feed', color: 'bad', message: "gossamer build FAILED ${env.BUILD_URL}", teamDomain: "${SLACKORG}", token:"${SLACKTOKEN}" - } - } -} diff --git a/legacy_build/build.sh b/legacy_build/build.sh deleted file mode 100644 index 35f34b9..0000000 --- a/legacy_build/build.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash -# e.g., -# ./build.sh 0.1 pnix.tar.gz pmac.tar.gz pwin.tar.gz pnix-latest.tar.gz pmac-latest.tar.gz pwin-latest.tar.gz mybucket /mypath/ -set -e -version=$1 -packageNameNix=$2 -packageNameMac=$3 -packageNameWindows=$4 -packageNameNixLatest=$5 -packageNameMacLatest=$6 -packageNameWindowsLatest=$7 -S3BUCKET=$8 -bucketPath=$9 -mkdir build -export GOOS="linux" -export GOARCH="amd64" -go test ./... -v -go build -o ./build/gossamer -ldflags "-X main.version=$version" -cd ./build && tar zcfv ../$packageNameNix . && cd .. -export GOOS="darwin" -export GOARCH="amd64" -go build -o ./build/gossamer -ldflags "-X main.version=$version" -cd ./build && tar zcfv ../$packageNameMac . && cd .. -export GOOS="windows" -export GOARCH="amd64" -go build -o ./build/gossamer.exe -ldflags "-X main.version=$version" -cd ./build && tar zcfv ../$packageNameWindows . && cd .. -aws s3 cp $packageNameNix s3://$S3BUCKET/$bucketPath$packageNameNix -aws s3 cp $packageNameNix s3://$S3BUCKET/$bucketPath$packageNameNixLatest -aws s3 cp $packageNameMac s3://$S3BUCKET/$bucketPath$packageNameMac -aws s3 cp $packageNameMac s3://$S3BUCKET/$bucketPath$packageNameMacLatest -aws s3 cp $packageNameWindows s3://$S3BUCKET/$bucketPath$packageNameWindows -aws s3 cp $packageNameWindows s3://$S3BUCKET/$bucketPath$packageNameWindowsLatest diff --git a/main.go b/main.go index af0f0cb..e1d1040 100644 --- a/main.go +++ b/main.go @@ -1,208 +1,110 @@ package main import ( - "flag" - "fmt" + "fmt" "os" - "os/signal" - "syscall" - "time" - - "github.com/GESkunkworks/gossamer/goslogger" - "github.com/GESkunkworks/gossamer/gossamer" + "flag" + "./gossamer" + "github.com/GESkunkworks/gossamer/goslogger" + "github.com/GESkunkworks/acfmgr" ) +// make the config obj avail to this package globablly +var gc *gossamer.Config + +func handle(err error) { + if err != nil { + goslogger.Loggo.Error("fatal error", "error", err) + os.Exit(1) + } +} + var version string func main() { - // set up flags - var outFile, roleArn, logFile, - profile, serialNumber, tokenCode, - region, loglevel, rolesFile, - profileEntryName, modeForce string - var sessionDuration, renewThresholdInt64, secondsInt64 int64 - var versionFlag, daemonFlag, forceRefresh, purgeCredFileFlag bool - roleSessionName := "gossamer" - flag.StringVar(&outFile, "o", "./gossamer_creds", "Output credentials file.") - flag.StringVar(&roleArn, "a", "", "Role ARN to assume.") - flag.StringVar(&rolesFile, "rolesfile", "", "File that contains json list of roles to assume and add to file.") - flag.StringVar(&logFile, "logfile", "gossamer.log.json", "JSON logfile location") - flag.StringVar(&profile, "profile", "", "Cred file profile to use. This overrides the default of using instance role from metadata.") - flag.StringVar(&serialNumber, "serialnumber", "", "Serial number of MFA device") - flag.StringVar(&tokenCode, "tokencode", "", "Token code of mfa device.") - flag.StringVar(®ion, "region", "us-east-1", "Region mandatory in mfa and profile mode") - flag.StringVar(&loglevel, "loglevel", "info", "Log level (info or debug)") - flag.StringVar(&modeForce, "modeforce", "", "Force a specific mode (e.g., 'mfa_noassume')") - flag.StringVar(&profileEntryName, "entryname", "gossamer", "when used with single ARN this is the entry name that will be added to the creds file (e.g., '[test-env]')") - flag.Int64Var(&sessionDuration, "duration", 3600, "Duration of token in seconds. Duration longer than 3600 seconds only supported by AWS when assuming a single role per tokencode. When assuming multiple roles from rolesfile max duration will always be 3600 as restricted by AWS. (min=900, max=[read AWS docs]) ") - flag.Int64Var(&renewThresholdInt64, "t", 10, " threshold in minutes.") - flag.Int64Var(&secondsInt64, "s", 300, "Duration in seconds to wait between checks.") - flag.BoolVar(&versionFlag, "v", false, "print version and exit") - flag.BoolVar(&daemonFlag, "daemon", false, "run as daemon checking every -s duration") - flag.BoolVar(&forceRefresh, "force", false, "force refresh even if token not yet expired") - flag.BoolVar(&purgeCredFileFlag, "purgecreds", false, "Purge managed entries from credentials file and exit") - flag.Parse() - if versionFlag { - fmt.Printf("gossamer %s\n", version) + var gfl gossamer.GossFlags + flag.StringVar(&gfl.ConfigFile, "c", "", "path to config file that overrides all other parameters") + flag.StringVar(&gfl.RolesFile, "rolesfile", "", "LEGACY: File that contains json list of roles to assume and add to file.") + flag.StringVar(&gfl.RoleArn, "a", "", "Role ARN to assume.") + flag.StringVar(&gfl.OutFile, "o", "./gossamer_creds", "Output credentials file.") + flag.StringVar(&gfl.LogFile, "logfile", "gossamer.log.json", "JSON logfile location") + flag.StringVar(&gfl.LogLevel, "loglevel", "info", "Log level (info or debug)") + flag.StringVar(&gfl.Profile, "profile", "", "Cred file profile to use. This overrides the default of using instance role from metadata.") + flag.StringVar(&gfl.SerialNumber, "serialnumber", "", "Serial number of MFA device") + flag.StringVar(&gfl.TokenCode, "tokencode", "", "Token code of mfa device.") + flag.StringVar(&gfl.Region, "region", "us-east-1", "Region mandatory in mfa and profile mode") + flag.StringVar(&gfl.ProfileEntryName, "entryname", "gossamer", "when used with single ARN this is the entry name that will be added to the creds file (e.g., '[test-env]')") + flag.StringVar(&gfl.GeneratedConfigOutputFile, "generate", "", "translates arguments into config file") + flag.Int64Var(&gfl.SessionDuration, "duration", 3600, "Duration of token in seconds. Duration longer than 3600 seconds only supported by AWS when assuming a single role per tokencode. When assuming multiple roles from rolesfile max duration will always be 3600 as restricted by AWS. (min=900, max=[read AWS docs]) ") + flag.BoolVar(&gfl.VersionFlag, "v", false, "print version and exit") + flag.BoolVar(&gfl.ForceRefresh, "force", false, "LEGACY: used to force refresh even if token not yet expired but this field is ignored now") + //TODO: Add positional args as source type for CParam + flag.Parse() + if gfl.VersionFlag { + fmt.Printf("gossamer %s\n", version) + os.Exit(0) + } + gfl.DaemonFlag = false //TODO: reimplement daemon mode maybe + goslogger.SetLogger(gfl.DaemonFlag, gfl.LogFile, gfl.LogLevel) + goslogger.Loggo.Info("Starting gossamer") + gc = &gossamer.GConf + var err error + if gfl.GeneratedConfigOutputFile == "@sample" { + sampleConfigFilename := "generated-sample-config.yml" + sampleConfig := gossamer.GenerateConfigSkeleton() + err = gossamer.WriteConfigToFile(sampleConfig, sampleConfigFilename); handle(err) + goslogger.Loggo.Info("wrote sample config to file. Exiting", "filename", sampleConfigFilename) os.Exit(0) } - // if daemon just log to file - goslogger.SetLogger(daemonFlag, logFile, loglevel) - goslogger.Loggo.Info("gossamer: assume-role via instance role", "version", version) - // exit if no roleArn or file specified - var accounts []gossamer.Account - var err error - if rolesFile != "" { - accounts, err = gossamer.LoadArnsFile(rolesFile) + if gfl.ConfigFile == "" { + goslogger.Loggo.Info("no config file provided so attempting to convert legacy arguments into new config format") + err = gc.ConvertLegacyFlagsToConfig(&gfl); handle(err) + } else { + err := gc.ParseConfigFile(gfl.ConfigFile) if err != nil { - panic(err) + fmt.Printf("Error parsing config file: '%s'. Continuing with parameter defaults\n", err.Error()) } } - if roleArn == "" && rolesFile == "" && modeForce != "mfa_noassume" { - goslogger.Loggo.Info("modeForce info", "modeForce", modeForce) - goslogger.Loggo.Error("must specify role ARN with '-a' or '-rolesfile'. Exiting.") - os.Exit(0) - } - if roleArn != "" && rolesFile == "" { - // just building one account struct - acct := gossamer.Account{RoleArn: roleArn, AccountName: profileEntryName, Region: region} - accounts = append(accounts, acct) - } - if modeForce == "mfa_noassume" { - // just building one account struct - acct := gossamer.Account{RoleArn: "NA", AccountName: profileEntryName, Region: region} - accounts = append(accounts, acct) - } - if len(accounts) == 0 && modeForce != "mfa_noassume" { - goslogger.Loggo.Info("modeForce info", "modeForce", modeForce) - goslogger.Loggo.Error("must specify role ARN with '-a' or '-rolesfile'. Exiting.") - os.Exit(0) - } - if 900 > sessionDuration { - goslogger.Loggo.Info("sessionDuration is outside threshold (min=900)", "sessionDuration", sessionDuration) - goslogger.Loggo.Info("exiting...") - os.Exit(0) - } - goslogger.Loggo.Info("OPTIONS", "parsed outfile", outFile) - goslogger.Loggo.Info("OPTIONS", "parsed arn ", roleArn) - goslogger.Loggo.Info("OPTIONS", "parsed duration", sessionDuration) - goslogger.Loggo.Info("OPTIONS", "parsed threshold", renewThresholdInt64) - goslogger.Loggo.Info("OPTIONS", "parsed between check duration", secondsInt64) - goslogger.Loggo.Info("OPTIONS", "parsed daemon mode", daemonFlag) - goslogger.Loggo.Info("OPTIONS", "parsed profile", profile) - goslogger.Loggo.Info("OPTIONS", "parsed region", region) - goslogger.Loggo.Info("OPTIONS", "parsed serialNumber", serialNumber) - goslogger.Loggo.Info("OPTIONS", "parsed tokenCode", tokenCode) - goslogger.Loggo.Info("OPTIONS", "parsed forceRefresh", forceRefresh) - goslogger.Loggo.Info("OPTIONS", "parsed modeForce", modeForce) - // recast some vars for time.Duration use later - renewThreshold := float64(renewThresholdInt64) - seconds := float64(secondsInt64) + total_count := 0 + // fmt.Println(gc.Dump()) + for _, flow := range(gc.Flows) { + // call valiate to make sure user didn't put crazy stuff in config + _, err = flow.Validate(); handle(err) + // set up session to write to credentials file + c , err := acfmgr.NewCredFileSession(gc.OutFile); handle(err) + // regardless of the flow type we'll always run primary + err = flow.ExecutePrimary(); handle(err) + // queue entries to write to file + goslogger.Loggo.Info("queueing primary assumptions to write to file") + pfisPrimary, err := flow.PAss.GetAcfmgrProfileInputs(); handle(err) + count := 0 + for _, pfi := range(pfisPrimary) { + err = c.NewEntry(pfi) + if err == nil { + count++ + } + } + // now handle SecondaryAssertions (if any) + if !flow.NoSAss() { + err = flow.GetSAss(); handle(err) + goslogger.Loggo.Debug("flow > saml > GetSAss: done") + goslogger.Loggo.Info("queueing secondary assumptions to write to file") + pfisSecondary, err := flow.SAss.GetAcfmgrProfileInputs(); handle(err) + for _, pfi := range(pfisSecondary) { + err = c.NewEntry(pfi) + if err == nil { + count++ + } + } + } - opts := gossamer.RunnerOptions{ - OutFile: outFile, - Accounts: accounts, - RoleSessionName: roleSessionName, - Profile: profile, - SerialNumber: serialNumber, - TokenCode: tokenCode, - RenewThreshold: renewThreshold, - Seconds: seconds, - SessionDuration: sessionDuration, - DaemonFlag: daemonFlag, - Mode: "instance-profile", - Region: region, - Force: forceRefresh} - // determine if we're just purging file and exiting - if purgeCredFileFlag { - err = gossamer.DeleteCredFileEntries(&opts) - os.Exit(0) - } - // figure out which mode we need to run in - if modeForce != "" { - opts.Mode = modeForce - } else { - opts.Mode = gossamer.ModeDecider(&opts) - } - if opts.Mode == "mfa" || opts.Mode == "mfa_noassume" { - goslogger.Loggo.Warn("config mismatch, cannot run as daemon in 'mfa*' mode, unsetting daemonFlag") - opts.DaemonFlag = false - } - if opts.DaemonFlag { - // Go signal notification works by sending `os.Signal` - // values on a channel. We'll create a channel to - // receive these notifications (we'll also make one to - // notify us when the program can exit). - sigs := make(chan os.Signal, 1) - done := make(chan bool, 1) - // `signal.Notify` registers the given channel to - // receive notifications of the specified signals. - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - - // This goroutine executes a blocking receive for - // signals. When it gets one it'll print it out - // and then notify the program that it can finish. - go sigCatcher(sigs, done) - go runner(&opts) - goslogger.Loggo.Info("Running and awaiting signal...") - <-done - goslogger.Loggo.Info("exiting") - } else { - // no daemon, one time run - runner(&opts) - } -} - -// sigCatcher waits for os signals to terminate gracefully -// after it receives a signal on the sigs channel. -// main() waits for a bool on the done channel. -func sigCatcher(sigs chan os.Signal, done chan bool) { - sig := <-sigs - goslogger.Loggo.Info("received signal", "signal", sig) - done <- true -} - -func handleGenErr(err error) { - if err != nil { - goslogger.Loggo.Error("Error generating cred", "error", err) - } -} - -// runner, in daemon mode: loops through continuously checking for credential expiration in the creds file -// in standalone mode it just checks once -func runner(opts *gossamer.RunnerOptions) { - goslogger.Loggo.Debug("entered function", "function", "runner") - for { - expired, err := gossamer.ReadExpire(opts.OutFile, opts.RenewThreshold) - if err != nil { - panic(err) - } - if expired || opts.Force { - switch opts.Mode { - case "mfa": - err = gossamer.GenerateNewMfa(opts, opts.Accounts) - case "mfa_noassume": - err = gossamer.GenerateNewMfa(opts, opts.Accounts) - case "profile-only": - err = gossamer.GenerateNewProfile(opts, opts.Accounts) - default: - for _, acct := range opts.Accounts { - goslogger.Loggo.Info("Attempting assumption", "ARN", acct.RoleArn) - err = gossamer.GenerateNewMeta(opts, acct) - handleGenErr(err) - time.Sleep(time.Second * time.Duration(1)) - } - } - handleGenErr(err) - } else { - goslogger.Loggo.Info("Token not yet expired. Exiting with no action.") - } - if opts.DaemonFlag { - duration := time.Second * time.Duration(opts.Seconds) - goslogger.Loggo.Info("Sleeping", "seconds", duration) - time.Sleep(duration) - } else { - break - } + // write all entries from this flow to file + err = c.AssertEntries() + if err != nil { + goslogger.Loggo.Error("error writing cred entries to file", "err", err) + } + total_count = total_count + count + goslogger.Loggo.Info("Wrote flow entries to file", "count", count, "flow", flow.Name) } + goslogger.Loggo.Info("done", "entries_written", total_count) } diff --git a/rolesfile_sample.json b/samples/rolesfile_sample.json similarity index 100% rename from rolesfile_sample.json rename to samples/rolesfile_sample.json