diff --git a/config.example.yml b/config.example.yml index 19f9420..02ee96f 100644 --- a/config.example.yml +++ b/config.example.yml @@ -51,4 +51,4 @@ sessions: directory: sessions enabled: true sshcert: - graceperiod: 4h + graceperiod: 4h \ No newline at end of file diff --git a/config/alertsystem.go b/config/alertsystem.go new file mode 100644 index 0000000..4bc24e8 --- /dev/null +++ b/config/alertsystem.go @@ -0,0 +1,98 @@ +package config + +import ( + "math" + "time" +) + +func Alert(c chan AlertInfo, env *Env) { + go listenForLoginAlert(c, env) +} + +func listenForLoginAlert(c chan AlertInfo, env *Env) { + // key is username, value is last login time + sshLoginData := make(map[string]time.Time) + // key is string version of IP network address and val is bool + seenNetworks := make(map[string]bool) + sshLoginThresholdData := New() + const maxDataPoints = 1000000 + var doAlert bool + + for alertInfo := range c { + networkBeenSeen := seenNetworks[alertInfo.IP.String()] + if !networkBeenSeen { + alertInfo.NewNetwork = true + doAlert = true + } + if lastLoginTime, ok := sshLoginData[alertInfo.User]; ok { + timeSince := float64(alertInfo.Timestamp.Sub(lastLoginTime)) + + addToDeque(sshLoginThresholdData, timeSince, maxDataPoints) + mean := float64(mean(sshLoginThresholdData)) + threshold := (3 * stdev(sshLoginThresholdData, mean)) + mean + + if timeSince > threshold { + alertInfo.BeenAWhile = true + doAlert = true + } + } else { // first login for this user + alertInfo.FirstLogin = true + doAlert = true + } + sshLoginData[alertInfo.User] = alertInfo.Timestamp + if doAlert || !alertInfo.Success { + go printAlert(alertInfo, env) + } + } +} + +// deque is fixed size so it doesn't just keep growing infinitely with more logins. +func addToDeque(deque *Deque, timeSince float64, maxDataPoints int) { + if deque.Size() >= maxDataPoints { + deque.PopRight() + } + deque.PushLeft(timeSince) +} + +func mean(deque *Deque) float64 { + sum := 0.0 + for idx := 0; idx < deque.Size(); idx++ { + val := deque.PopRight().(float64) + sum += float64(val) + deque.PushLeft(val) + } + return sum / float64(deque.Size()) +} + +func stdev(deque *Deque, mean float64) float64 { + sum := 0.0 + for idx := 0; idx < deque.Size(); idx++ { + val := deque.PopRight().(float64) + sum += (mean - val) * (mean - val) + deque.PushLeft(val) + } + return math.Sqrt(sum / float64(deque.Size())) +} + +func printAlert(alertInfo AlertInfo, env *Env) { + alertString := "ALERT!\n" + if alertInfo.NewNetwork { + alertString += "User just attempted connection from a new network.\n" + } + if !alertInfo.Success { + alertString += "User unsuccessfully attempted SSH connection.\n" + } + if alertInfo.BeenAWhile { + alertString += "This is the first time this user has attempted SSH connection in a long time.\n" + } + if alertInfo.FirstLogin { + alertString += "This user has never attempted SSH connection before.\n" + } + alertString += "SSH login details for this alert:\n" + alertString += "User: " + alertInfo.User + "\n" + alertString += "Timestamp: " + alertInfo.Timestamp.Format("Mon Jan _2 15:04:05 2006") + "\n" + alertString += "Network IP: " + alertInfo.IP.String() + "\n" + alertString += "If this information is expected, you may ignore this alert." + // atomic. + env.Blue.Println(alertString) +} diff --git a/config/deque.go b/config/deque.go new file mode 100644 index 0000000..bc3a619 --- /dev/null +++ b/config/deque.go @@ -0,0 +1,155 @@ +// CookieJar - A contestant's algorithm toolbox +// Copyright (c) 2013 Peter Szilagyi. All rights reserved. +// +// CookieJar is dual licensed: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// The toolbox is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// Alternatively, the CookieJar toolbox may be used in accordance with the terms +// and conditions contained in a signed written agreement between you and the +// author(s). + +// Package deque implements a double ended queue supporting arbitrary types +// (even a mixture). +// +// Internally it uses a dynamically growing circular slice of blocks, resulting +// in faster resizes than a simple dynamic array/slice would allow, yet less gc +// overhead. +package config + +// The size of a block of data +const blockSize = 4096 + +// Double ended queue data structure. +type Deque struct { + leftIdx int + leftOff int + rightIdx int + rightOff int + + blocks [][]interface{} + left []interface{} + right []interface{} +} + +// Creates a new, empty deque. +func New() *Deque { + result := new(Deque) + result.blocks = [][]interface{}{make([]interface{}, blockSize)} + result.right = result.blocks[0] + result.left = result.blocks[0] + return result +} + +// Pushes a new element into the queue from the right, expanding it if necessary. +func (d *Deque) PushRight(data interface{}) { + d.right[d.rightOff] = data + d.rightOff++ + if d.rightOff == blockSize { + d.rightOff = 0 + d.rightIdx = (d.rightIdx + 1) % len(d.blocks) + + // If we wrapped over to the left, insert a new block and update indices + if d.rightIdx == d.leftIdx { + buffer := make([][]interface{}, len(d.blocks)+1) + copy(buffer[:d.rightIdx], d.blocks[:d.rightIdx]) + buffer[d.rightIdx] = make([]interface{}, blockSize) + copy(buffer[d.rightIdx+1:], d.blocks[d.rightIdx:]) + d.blocks = buffer + d.leftIdx++ + d.left = d.blocks[d.leftIdx] + } + d.right = d.blocks[d.rightIdx] + } +} + +// Pops out an element from the queue from the right. Note, no bounds checking are done. +func (d *Deque) PopRight() (res interface{}) { + d.rightOff-- + if d.rightOff < 0 { + d.rightOff = blockSize - 1 + d.rightIdx = (d.rightIdx - 1 + len(d.blocks)) % len(d.blocks) + d.right = d.blocks[d.rightIdx] + } + res, d.right[d.rightOff] = d.right[d.rightOff], nil + return +} + +// Returns the rightmost element from the deque. No bounds are checked. +func (d *Deque) Right() interface{} { + if d.rightOff > 0 { + return d.right[d.rightOff-1] + } else { + return d.blocks[(d.rightIdx-1+len(d.blocks))%len(d.blocks)][blockSize-1] + } +} + +// Pushes a new element into the queue from the left, expanding it if necessary. +func (d *Deque) PushLeft(data interface{}) { + d.leftOff-- + if d.leftOff < 0 { + d.leftOff = blockSize - 1 + d.leftIdx = (d.leftIdx - 1 + len(d.blocks)) % len(d.blocks) + + // If we wrapped over to the right, insert a new block and update indices + if d.leftIdx == d.rightIdx { + d.leftIdx++ + buffer := make([][]interface{}, len(d.blocks)+1) + copy(buffer[:d.leftIdx], d.blocks[:d.leftIdx]) + buffer[d.leftIdx] = make([]interface{}, blockSize) + copy(buffer[d.leftIdx+1:], d.blocks[d.leftIdx:]) + d.blocks = buffer + } + d.left = d.blocks[d.leftIdx] + } + d.left[d.leftOff] = data +} + +// Pops out an element from the queue from the left. Note, no bounds checking are done. +func (d *Deque) PopLeft() (res interface{}) { + res, d.left[d.leftOff] = d.left[d.leftOff], nil + d.leftOff++ + if d.leftOff == blockSize { + d.leftOff = 0 + d.leftIdx = (d.leftIdx + 1) % len(d.blocks) + d.left = d.blocks[d.leftIdx] + } + return +} + +// Returns the leftmost element from the deque. No bounds are checked. +func (d *Deque) Left() interface{} { + return d.left[d.leftOff] +} + +// Checks whether the queue is empty. +func (d *Deque) Empty() bool { + return d.leftIdx == d.rightIdx && d.leftOff == d.rightOff +} + +// Returns the number of elements in the queue. +func (d *Deque) Size() int { + if d.rightIdx > d.leftIdx { + return (d.rightIdx-d.leftIdx)*blockSize - d.leftOff + d.rightOff + } else if d.rightIdx < d.leftIdx { + return (len(d.blocks)-d.leftIdx+d.rightIdx)*blockSize - d.leftOff + d.rightOff + } else { + return d.rightOff - d.leftOff + } +} + +// Clears out the contents of the queue. +func (d *Deque) Reset() { + d.leftIdx = 0 + d.rightIdx = 0 + d.leftOff = 0 + d.rightOff = 0 + d.left = d.blocks[0] + d.right = d.blocks[0] +} diff --git a/config/main.go b/config/main.go index 58c8f51..fb187c7 100644 --- a/config/main.go +++ b/config/main.go @@ -12,6 +12,7 @@ import ( "github.com/jinzhu/gorm" _ "github.com/jinzhu/gorm/dialects/mysql" // Load MySQL for GORM _ "github.com/jinzhu/gorm/dialects/sqlite" // Load SQLite for GORM + "github.com/spf13/viper" "google.golang.org/api/option" ) @@ -20,6 +21,7 @@ const configFile = "config.yml" // Load initializes the Env pointer with data from the database and elsewhere func Load(forceCerts bool, webAddr string, sshAddr string, sshProxyAddr string, monAddr string) *Env { + vconfig := viper.New() vconfig.SetConfigFile(configFile) @@ -80,6 +82,8 @@ func Load(forceCerts bool, webAddr string, sshAddr string, sshProxyAddr string, logsBucket = storageClient.Bucket(bucketName) } + alertChan := make(chan AlertInfo) + env := &Env{ ForceGeneration: forceCerts, PKPassphrase: vconfig.GetString("pkpassphrase"), @@ -95,12 +99,14 @@ func Load(forceCerts bool, webAddr string, sshAddr string, sshProxyAddr string, Yellow: yellow, Blue: blue, Magenta: magenta, + AlertChannel: alertChan, HTTPPort: webAddr, SSHPort: sshAddr, SSHProxyPort: sshProxyAddr, MonPort: monAddr, } + Alert(alertChan, env) if vconfig.GetBool("debug.info.enabled") { printDebugInfo(env) } diff --git a/config/structs.go b/config/structs.go index 67fd4e8..a2d5458 100644 --- a/config/structs.go +++ b/config/structs.go @@ -29,12 +29,25 @@ type Env struct { Yellow *ColorLog Blue *ColorLog Magenta *ColorLog + AlertChannel chan AlertInfo SSHPort string SSHProxyPort string HTTPPort string MonPort string } +//used for email alerts on new logins, unsuccessful logins, and auths that occur after a long period of inactivity. +type AlertInfo struct { + User string + IP net.IP + Timestamp time.Time + LoginType string + Success bool + FirstLogin bool + NewNetwork bool + BeenAWhile bool +} + // WsClient is a struct that contains a websockets underlying data object type WsClient struct { Client *websocket.Conn diff --git a/ssh/server.go b/ssh/server.go index dbcfd52..1250ba7 100644 --- a/ssh/server.go +++ b/ssh/server.go @@ -10,9 +10,8 @@ import ( "sync" "time" - "github.com/notion/bastion/proxyprotocol" - "github.com/notion/bastion/config" + "github.com/notion/bastion/proxyprotocol" "golang.org/x/crypto/ssh" ) @@ -139,6 +138,35 @@ func handleSession(newChannel ssh.NewChannel, SSHConn *ssh.ServerConn, proxyAddr serverClient.ProxyTo = host rawProxyConn, err := net.Dial("tcp", proxyAddr) + + ipAddr := rawProxyConn.RemoteAddr().(*net.TCPAddr).IP + var network net.IP + if ipAddr.To4() != nil { + mask := net.CIDRMask(24, 32) + network = ipAddr.Mask(mask) + } else { + mask := net.CIDRMask(48, 128) + network = ipAddr.Mask(mask) + } + + alertInfo := &config.AlertInfo{ + User: SSHConn.User(), + IP: network, + Timestamp: time.Now(), + LoginType: "ssh", + Success: true, + FirstLogin: false, + NewNetwork: false, + BeenAWhile: false, + } + + if !authed { + alertInfo.Success = false + env.AlertChannel <- *alertInfo + } else { + env.AlertChannel <- *alertInfo + } + if err != nil { env.Red.Println("Unable to establish connection to TCP Socket:", err) serverClient.Errors = append(serverClient.Errors, fmt.Errorf("Unable to establish remote TCP Socket: %s", err)) diff --git a/web/auth.go b/web/auth.go index 453621d..e8cea31 100644 --- a/web/auth.go +++ b/web/auth.go @@ -37,7 +37,6 @@ func authMiddleware(env *config.Env) func(c *gin.Context) { return func(c *gin.Context) { path := strings.TrimSpace(c.Request.URL.Path) session := sessions.Default(c) - auth := session.Get("loggedin") otpAuth := session.Get("otpauthed") if otpAuth != nil { @@ -117,7 +116,7 @@ func checkOtp(env *config.Env) func(c *gin.Context) { } } -func setupotp(env *config.Env) func(c *gin.Context) { +func setupOtp(env *config.Env) func(c *gin.Context) { return func(c *gin.Context) { session := sessions.Default(c) sessionUser := session.Get("user").(*config.User) diff --git a/web/main.go b/web/main.go index 6462240..a38ebb2 100644 --- a/web/main.go +++ b/web/main.go @@ -90,7 +90,7 @@ func Serve(addr string, env *config.Env) { apiGroup.GET("/sessions/:id", sessionID(env)) apiGroup.POST("/otp", checkOtp(env)) - apiGroup.GET("/setupotp", setupotp(env)) + apiGroup.GET("/setupotp", setupOtp(env)) } }