hq/app/logic/IamOutdatedCredentials.scala (136 lines of code) (raw):
package logic
import config.Config
import config.Config.{daysBetweenFinalNotificationAndRemediation, daysBetweenWarningAndFinalNotification}
import db.IamRemediationDb
import model._
import org.joda.time.{DateTime, Days}
import play.api.Logging
import utils.attempt.{Attempt, FailedAttempt, Failure}
import scala.concurrent.ExecutionContext
object IamOutdatedCredentials extends Logging {
/**
* Look through all credentials reports to find users with expired credentials,
* see below for more detail (`identifyUsersWithOutdatedCredentials`).
*/
def identifyAllUsersWithOutdatedCredentials(accountCredentialReports: List[(AwsAccount, CredentialReportDisplay)], now: DateTime): List[(AwsAccount, List[IAMUser])] = {
accountCredentialReports.map { case (awsAccount, credentialReport) =>
(awsAccount, identifyUsersWithOutdatedCredentials(credentialReport, now))
}
}
/**
* Looks through the credentials report to identify users with Access Keys that are older than we allow.
*/
private[logic] def identifyUsersWithOutdatedCredentials(credentialReportDisplay: CredentialReportDisplay, now: DateTime): List[IAMUser] = {
val machineUsersWithOutdatedKeys = credentialReportDisplay.machineUsers.filter(user => hasOutdatedMachineKey(List(user.key1, user.key2), now))
val humanUsersWithOutdatedKeys = credentialReportDisplay.humanUsers.filter(user => hasOutdatedHumanKey(List(user.key1, user.key2), now))
// Filter out any users tagged with opt-out tag, so we can enable the job on accounts
// while attempting to rotate difficult keys. This should be used as a last resort only.
(machineUsersWithOutdatedKeys ++ humanUsersWithOutdatedKeys).toList
.filterNot(_.tags.exists(_.key == Config.outdatedCredentialOptOutUserTag))
}
private def hasOutdatedHumanKey(keys: List[AccessKey], now: DateTime): Boolean = keys.exists(isOutdatedHumanKey(_, now))
private def hasOutdatedMachineKey(keys: List[AccessKey], now: DateTime): Boolean = keys.exists(isOutdatedMachineKey(_, now))
private def isOutdatedHumanKey(key: AccessKey, now: DateTime): Boolean = {
key.lastRotated.exists { date =>
// using minus 1 so that we return true if the last rotated date is exactly on the cadence date
date.isBefore(now.minusDays(Config.iamHumanUserRotationCadence.toInt - 1))
} && key.keyStatus == AccessKeyEnabled
}
private def isOutdatedMachineKey(key: AccessKey, now: DateTime): Boolean = {
key.lastRotated.exists { date =>
// using minus 1 so that we return true if the last rotated date is exactly on the cadence date
date.isBefore(now.minusDays(Config.iamMachineUserRotationCadence.toInt - 1))
} && key.keyStatus == AccessKeyEnabled
}
/**
* Given an IAMUser (in an AWS account), look up that user's activity history form the Database.
*/
def lookupActivityHistory(accountIdentifiedUsers: List[(AwsAccount, List[IAMUser])], dynamo: IamRemediationDb, tableName: String)(implicit ec: ExecutionContext): Attempt[List[IamUserRemediationHistory]] = {
for {
remediationHistoryByAccount <- Attempt.traverse(accountIdentifiedUsers) { case (awsAccount, identifiedUsers) =>
// for each account with vulnerable user(s), do a DB lookup for each identified user to get activity history
Attempt.traverse(identifiedUsers) { identifiedUser =>
dynamo.lookupIamUserNotifications(identifiedUser, awsAccount, tableName).map { userActivityHistory =>
IamUserRemediationHistory(awsAccount, identifiedUser, userActivityHistory)
}
}
}
} yield {
// no need to have these separated by account any more
remediationHistoryByAccount.flatten
}
}
/**
* Looks through the candidate's remediation history and outputs the work to be done per access key.
* This means that the same user could appear in the output list twice, because both of their keys may require an operation.
* By comparing the current date with the date of the most recent activity, we know which operation to perform next.
*/
def calculateOutstandingAccessKeyOperations(remediationHistories: List[IamUserRemediationHistory], now: DateTime): List[RemediationOperation] = {
for {
userRemediationHistory <- remediationHistories
vulnerableKey <- identifyVulnerableKeys(userRemediationHistory, now)
keyPreviousAlert = identifyMostRecentActivity(userRemediationHistory, vulnerableKey)
keyNextActivity <- identifyRemediationOperation(keyPreviousAlert, now, userRemediationHistory, vulnerableKey)
} yield keyNextActivity
}
private[logic] def identifyVulnerableKeys(remediationHistory: IamUserRemediationHistory, now: DateTime): List[AccessKey] = {
val user = remediationHistory.iamUser
if (user.isHuman) List(user.key1, user.key2).filter(isOutdatedHumanKey(_, now))
else List(user.key1, user.key2).filter(isOutdatedMachineKey(_, now))
}
private[logic] def identifyMostRecentActivity(remediationHistory: IamUserRemediationHistory, vulnerableKey: AccessKey): Option[IamRemediationActivity] = {
//TODO lastRotatedDate should not be an Option, because every IAM access key has a last rotated date. Change SHQ's model.
vulnerableKey.lastRotated match {
case Some(lastRotatedDate) =>
// filter activity list to find matching db records for given access key
val keyPreviousActivities = remediationHistory.activityHistory.filter(_.problemCreationDate.isEqual(lastRotatedDate))
keyPreviousActivities match {
case Nil =>
// there is no recent activity for the given access key, so return None.
None
case remediationActivities =>
// get the most recent remediation activity
Some(remediationActivities.maxBy(_.dateNotificationSent.getMillis))
}
case None =>
val name = remediationHistory.iamUser.username
val account = remediationHistory.awsAccount.name
logger.warn(s"$name in $account has an access key without a lastRotatedDate. Please investigate.")
None
}
}
private[logic] def identifyRemediationOperation(
mostRecentRemediationActivity: Option[IamRemediationActivity],
now: DateTime,
userRemediationHistory: IamUserRemediationHistory,
vulnerableKey: AccessKey
): Option[RemediationOperation] =
mostRecentRemediationActivity match {
case None =>
// If there is no recent activity, then the required operation must be a Warning.
Some(RemediationOperation(userRemediationHistory, Warning, OutdatedCredential, problemCreationDate = vulnerableKey.lastRotated.getOrElse(now)))
case Some(mostRecentActivity) =>
val finalWarningStartOfDay = mostRecentActivity.dateNotificationSent.plusDays(daysBetweenWarningAndFinalNotification).withTimeAtStartOfDay()
val remediationStartOfDay = mostRecentActivity.dateNotificationSent.plusDays(daysBetweenFinalNotificationAndRemediation).withTimeAtStartOfDay()
mostRecentActivity.iamRemediationActivityType match {
case Warning if now.isAfter(finalWarningStartOfDay) =>
// If the most recent activity is a Warning and the last notification was sent at least `Config.daysBetweenWarningAndFinalNotification` ago,
// the required operation is a FinalWarning.
Some(RemediationOperation(userRemediationHistory, FinalWarning, mostRecentActivity.iamProblem, mostRecentActivity.problemCreationDate))
case FinalWarning if now.isAfter(remediationStartOfDay) =>
// If the most recent activity is a FinalWarning and the last notification was sent at least `Config.daysBetweenFinalNotificationAndRemediation` ago,
// the required operation is Remediation.
Some(RemediationOperation(userRemediationHistory, Remediation, mostRecentActivity.iamProblem, mostRecentActivity.problemCreationDate))
case Remediation =>
val name = userRemediationHistory.iamUser.username
val account = userRemediationHistory.awsAccount.name
logger.warn(s"$name in $account has an access key of recent activity type Remediation, but the key is enabled. Please investigate as I will continue to attempt key disablement until rotated.")
Some(RemediationOperation(userRemediationHistory, Remediation, mostRecentActivity.iamProblem, mostRecentActivity.problemCreationDate))
case _ => None
}
}
/**
* To prevent non-PROD application instances from making changes to production AWS accounts, SHQ is
* configured with a list of the AWS accounts that this instance is allowed to affect.
* In addition, SHQ is configured with a list of AWS accounts to run the IamRemediationService on,
* because not all accounts are in a ready state for this service yet.
*/
def partitionOperationsByAllowedAccounts(operations: List[RemediationOperation], allowedAwsAccountIds: List[String], serviceAccountIds: List[String]): PartitionedRemediationOperations = {
val (allowed, forbidden) = operations.partition { remediationOperation =>
val accountId = remediationOperation.vulnerableCandidate.awsAccount.id
allowedAwsAccountIds.contains(accountId) && serviceAccountIds.contains(accountId)
}
PartitionedRemediationOperations(allowed, forbidden)
}
/**
* Users can have multiple credentials, and it can be tricky to know which one we have identified.
* From the full metadata for all a user's keys, we can look up the AccessKey's ID by comparing the
* creation dates with the key we are expecting.
*
* This might fail, because it may be that no matching key exists.
*/
def lookupCredentialId(badKeyCreationDate: DateTime, userCredentials: List[CredentialMetadata]): Attempt[CredentialMetadata] = {
val username = userCredentials.map(_.username).headOption.getOrElse("unknown username")
userCredentials.filter { credentialMetadata =>
credentialMetadata.creationDate.withMillisOfSecond(0) == badKeyCreationDate.withMillisOfSecond(0)
} match {
case singleMatchingKey :: Nil => Attempt.Right(singleMatchingKey)
case Nil =>
Attempt.Left(FailedAttempt(Failure(
"unable to identify matching access key in user's metadata",
s"I've made a list-access-keys AWS API call for $username, but I have not found a matching key in the response.",
500
)))
case _ =>
// This is an edge case where both the user's access keys both have the same creation date.
// This should be unlikely given the Credentials Reports creation dates are defined up to the second.
Attempt.Left(FailedAttempt(Failure(
s"both of $username's access keys have the exact same creation date - cannot decide which one to select for disablement",
s"I've hit an edge case for $username's access keys where both have the same creation date, please investigate as I can't decide which one to disable.",
500
)))
}
}
def formatRemediationOperation(remediationOperation: RemediationOperation): String = {
val problem = remediationOperation.iamProblem
val activity = remediationOperation.iamRemediationActivityType
val username = remediationOperation.vulnerableCandidate.iamUser.username
val accountId = remediationOperation.vulnerableCandidate.awsAccount.id
s"$problem $activity for user $username from account $accountId"
}
}