Skip to content

Commit

Permalink
[Sync] 17783 (#112)
Browse files Browse the repository at this point in the history
  • Loading branch information
panther-bot authored Mar 14, 2024
1 parent bbc9556 commit 0ac2299
Show file tree
Hide file tree
Showing 15 changed files with 926 additions and 13 deletions.
21 changes: 8 additions & 13 deletions serverless/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,21 @@
bucket = panther-public-sam-artifacts

iter:
set -e; \
for samapp in `ls`; do \
if [ -d $$samapp ]; then \
cd $$samapp; \
make --makefile=../Makefile $(action) samdir=$$samapp; \
cd ..; \
fi; \
done
find . -depth 1 -type d | xargs -I % make -C % --makefile=../Makefile $(action) samdir=%

publish:
sam build --use-container --build-image public.ecr.aws/sam/build-python3.11; sam package --s3-bucket $(bucket)-$(region) --output-template-file ../../cloudformation/panther-$(samdir)-$(region).yml
sam build --use-container --build-image public.ecr.aws/sam/build-python3.11; sam package --s3-bucket $(bucket)-$(region) --output-template-file ../../cloudformation/$(samdir)-$(region).yml

setup:
python3 -m venv venv; venv/bin/pip install -r src/requirements.txt; venv/bin/pip install pytest pylint
find . -depth 1 -type d | xargs -I % python3 -m venv %/venv
find . -depth 1 -type d | xargs -I % bash -c "%/venv/bin/pip install -r %/src/requirements.txt"
find . -depth 1 -type d | xargs -I % bash -c "%/venv/bin/pip install pytest pylint"

clean:
rm -rf venv
rm -rf */venv

test:
AWS_DEFAULT_REGION=us-west-2 venv/bin/pytest test/
find . -depth 1 -type d | xargs -I % bash -c "pushd %; AWS_DEFAULT_REGION=us-west-2 venv/bin/pytest test/; popd"

lint:
venv/bin/pylint -j 0 --max-line-length 140 --score no src/
find . -depth 1 -type d | xargs -I % bash -c "pushd %; venv/bin/pylint -j 0 --max-line-length 140 --score no src/; popd"
48 changes: 48 additions & 0 deletions serverless/panther-preflight-tools/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Preflight Tools User Guide

## Readiness Check

**Prerequisite**: A deployed "PantherDeploymentRole" in the aws account

Invoking the readiness check is simple. It does not require a payload and can either be invoked on the command line with something like this

```
aws lambda invoke --function-name "PantherReadinessCheck" --cli-binary-format raw-in-base64-out output.json
```

The result will end up in output.json in this example

```
[12:18] user@host $> aws lambda invoke --function-name "PantherReadinessCheck" --cli-binary-format raw-in-base64-out output.json
[12:18] user@host $> cat output.json
{"Message": "All evaluations were successful against the Deployment Role"}
```

Or in the console on the test page of the lambda utility:
https://console.aws.amazon.com/lambda/home#/functions/PantherReadinessCheck?tab=testing where the result will show up in the Details dropdown or in cloudwatch.

The return value of the lambda will be a json object either with a success message, or a failure message and a series of failures that were detected. Please return this result to your panther representative.

## Snowflake Credential Bootstrap

After creating the PANTHERACCOUNTADMIN user and ensuring it has ACCOUNTADMIN privs, you may invoke the lambda to populate the initial credential secrets.
Authenticate to your aws environment and region where the template was stood up, then run this, filling out the host parameter with your login url

```
aws lambda invoke\
--function-name "PantherSnowflakeCredentialBootstrap"\
--log-type Tail\
--payload '{"host": "https://myaccountid.snowflakecomputing.com"}'\
--cli-binary-format raw-in-base64-out /dev/stderr > /dev/null
```

This invocation should yeild a link and instructions to update the newly minted secret directly with your credentials.
After that is done, please run the validation step below as-is and return the result to your panther representative.

```
aws lambda invoke\
--function-name "PantherSnowflakeCredentialBootstrap"\
--log-type Tail\
--payload '{"validate": true}'\
--cli-binary-format raw-in-base64-out /dev/stderr > /dev/null
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Copyright (C) 2022 Panther Labs, Inc.
#
# The Panther SaaS is licensed under the terms of the Panther Enterprise Subscription
# Agreement available at https://panther.com/enterprise-subscription-agreement/.
# All intellectual property rights in and to the Panther SaaS, including any and all
# rights to access the Panther SaaS, are governed by the Panther Enterprise Subscription Agreement.
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# Copyright (C) 2022 Panther Labs, Inc.
#
# The Panther SaaS is licensed under the terms of the Panther Enterprise Subscription
# Agreement available at https://panther.com/enterprise-subscription-agreement/.
# All intellectual property rights in and to the Panther SaaS, including any and all
# rights to access the Panther SaaS, are governed by the Panther Enterprise Subscription Agreement.
"""
Lambda function to assist the user in setting up their AWS account
to accept a Panther deployment configured to use a connected, pre-existing
snowflake account
"""

from dataclasses import dataclass
import json
import os
from typing import Mapping
from urllib.parse import urlparse, ParseResult

import boto3
from botocore.exceptions import ClientError
import snowflake.connector

SECRETNAME = "panther-managed-accountadmin-secret"
SF_DOMAIN = ".snowflakecomputing.com"
USERNAME = "PANTHERACCOUNTADMIN"
PASSWORD_PLACEHOLDER = "PleaseReplaceMe"

# What region are we in
AWS_REGION = os.environ.get("AWS_REGION", "")
AWS_DEFAULT_REGION = os.environ.get("AWS_DEFAULT_REGION", "")
if not AWS_REGION and not AWS_DEFAULT_REGION:
raise EnvironmentError("Could not detect region")
REGION = AWS_REGION if AWS_REGION else AWS_DEFAULT_REGION

SECRET_URL = f"https://{REGION}.console.aws.amazon.com/secretsmanager/secret?name={SECRETNAME}&region={REGION}"

EDIT_SECRET_PROMPT = f"""Please navigate to {SECRET_URL} in your authenticated browser and click \
"Retrieve secret value" then "Edit". Add your password in place of the placeholder and save \
the secret. Then return to your terminal and execute the lambda again with "validate":true"""


@dataclass
class PantherSnowflakeCredential():
"""
Represent the credentials used by panther to authenticate to snowflake
"""
arn: str = ""
host: str = ""
account: str = ""
user: str = ""
password: str = PASSWORD_PLACEHOLDER
port: str = "443"

@staticmethod
def secret_exists(client: boto3.Session) -> bool:
"""
Checks for the existence of the managed accountadmin secret
return: true if exists, false if not
"""
try:
client.describe_secret(SecretId=SECRETNAME)
return True
except ClientError as error:
if error.response["Error"]["Code"] == "ResourceNotFoundException":
return False
raise

def create_secret(self, client: boto3.Session) -> None:
"""
Json-ifies the class and writes to the secret
return: ARN of newly created secret
"""
secret_string = json.dumps({
"account": self.account,
"host": self.host,
"port": self.port,
"user": self.user,
"password": self.password
})
resp = client.create_secret(
Name=SECRETNAME,
Description="Panther Labs, accountadmin snowflake credentials",
SecretString=secret_string,
)
self.arn = resp['ARN']

def test(self) -> None:
"""
Connects to snowflake to validate credentials
"""
snowflake.connector.connect(
user=self.user, password=self.password, account=self.account)


def credentials_from_secret(client: boto3.Session) -> PantherSnowflakeCredential:
"""
Populates a credential object from a known-existing secret
"""
if not PantherSnowflakeCredential.secret_exists(client):
raise ValueError(
"The snowflake credential secret was expected to exist, but does not.")

resp = client.get_secret_value(
SecretId=SECRETNAME
)
secret = json.loads(resp["SecretString"])
return PantherSnowflakeCredential(
arn=resp["ARN"],
account=secret["account"],
host=secret["host"],
port=secret["port"],
user=secret["user"],
password=secret["password"]
)


def parse_event_into_creds(event: Mapping[str, str]) -> PantherSnowflakeCredential:
"""
Validate, massage the input event and store it as a credential object
return: Instance of PantherSnowflakeCredentials representing the given input, save password
"""
for field in ["host"]:
if field not in event.keys():
raise ValueError(
f"Failed validating input, missing field '{field}' in payload")

user = event.get("user", USERNAME)
host = event["host"]

if user != USERNAME:
raise ValueError(f"User did not match required string {USERNAME}")

parsed: ParseResult = urlparse(host)
host = parsed.netloc
if not host:
host = parsed.path
if not host:
raise ValueError(
"Failed validating input for 'host' field: should be a hostname or uri with protocol")
if not host.endswith(SF_DOMAIN):
raise ValueError(
f"Failed validating input for 'host' field: host must end with {SF_DOMAIN}")

return PantherSnowflakeCredential(
host=host,
account=host.split(SF_DOMAIN)[0],
user=user
# Password is later populated by the user manually in the UI
# Port always defaults to 443
)


def lambda_handler(event: Mapping[str, str], _) -> str:
"""
Lambda entrypoint
"""
client = boto3.client("secretsmanager", region_name=REGION)
# Two execution modes for the lambda. Seed and validate the secret
if event.get("validate", False):
print("======VALIDATION MODE======")
# Check creds are changed
creds = credentials_from_secret(client)
if creds.password == PASSWORD_PLACEHOLDER:
raise ValueError(
f"It appears the secret was not modified from its placeholder value. {EDIT_SECRET_PROMPT}")

# Run cred test
try:
creds.test()
except:
print(
"Failed testing the snowflake credentials! Please check for correctness of host,user,password in the secret")
raise
return f"Validation succeeded for the secret. Please report back to your panther rep with this value: '{creds.arn}'"

print("======SEED CREDS======")
# Check that secret doesn't already exist
if PantherSnowflakeCredential.secret_exists(client):
raise FileExistsError(
f"The proposed secret '{SECRETNAME}' already exists in this account/region! Refusing to overwrite it.")
# Parse the event input
creds = parse_event_into_creds(event)
# Create secret
creds.create_secret(client)
return f"Creating the initial secret was successful. {EDIT_SECRET_PROMPT}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
snowflake-connector-python==3.3.1
boto3==1.29.2
botocore==1.32.2
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Copyright (C) 2022 Panther Labs, Inc.
#
# The Panther SaaS is licensed under the terms of the Panther Enterprise Subscription
# Agreement available at https://panther.com/enterprise-subscription-agreement/.
# All intellectual property rights in and to the Panther SaaS, including any and all
# rights to access the Panther SaaS, are governed by the Panther Enterprise Subscription Agreement.
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Copyright (C) 2022 Panther Labs, Inc.
#
# The Panther SaaS is licensed under the terms of the Panther Enterprise Subscription
# Agreement available at https://panther.com/enterprise-subscription-agreement/.
# All intellectual property rights in and to the Panther SaaS, including any and all
# rights to access the Panther SaaS, are governed by the Panther Enterprise Subscription Agreement.

from pytest import mark
from src import app

BASE_EVENT = {
"user": "PANTHERACCOUNTADMIN"
}

@mark.parametrize("event,valid", [
[{"asdfasdf": "asdfasdf"}, False],
[{"user":"test"}, False],
[{"host":"test"}, False],
[{"user":"PANTHERACCOUNTADMIN","host":"ryan.snowflakecomputing.com"}, True],
])
def test_parse_event_into_creds_validation_exception(event, valid):
try:
app.parse_event_into_creds(event)
except:
assert not valid
return

assert valid
return

@mark.parametrize("event,expected_host", [
[BASE_EVENT|{"host": "ryan.snowflakecomputing.com"}, "ryan.snowflakecomputing.com"],
[BASE_EVENT|{"host": "//ryan.snowflakecomputing.com"}, "ryan.snowflakecomputing.com"],
[BASE_EVENT|{"host": "http://ryan.snowflakecomputing.com"}, "ryan.snowflakecomputing.com"],
[BASE_EVENT|{"host": "https://ryan.snowflakecomputing.com"}, "ryan.snowflakecomputing.com"],
[BASE_EVENT|{"host": "snowflake://ryan.snowflakecomputing.com"}, "ryan.snowflakecomputing.com"],
[BASE_EVENT|{"host": "https://ryan.snowflakecomputing.com/"}, "ryan.snowflakecomputing.com"],
[BASE_EVENT|{"host": "https://ryan.snowflakecomputing.com/login"}, "ryan.snowflakecomputing.com"],
[BASE_EVENT|{"host": "pantherlabs-ryan.snowflakecomputing.com"}, "pantherlabs-ryan.snowflakecomputing.com"],
])
def test_parse_event_into_creds_host(event, expected_host):
creds = app.parse_event_into_creds(event)
assert creds.host == expected_host

@mark.parametrize("event,expected_account", [
[BASE_EVENT|{"host": "ryan.snowflakecomputing.com"}, "ryan"],
[BASE_EVENT|{"host": "https://ryan.snowflakecomputing.com/login"}, "ryan"],
[BASE_EVENT|{"host": "pantherlabs-ryan.snowflakecomputing.com"}, "pantherlabs-ryan"],
[BASE_EVENT|{"host": "pantherlabs-ryan_clone.snowflakecomputing.com"}, "pantherlabs-ryan_clone"],
])
def test_parse_event_into_creds_account(event, expected_account):
creds = app.parse_event_into_creds(event)
assert creds.account == expected_account
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Copyright (C) 2022 Panther Labs, Inc.
#
# The Panther SaaS is licensed under the terms of the Panther Enterprise Subscription
# Agreement available at https://panther.com/enterprise-subscription-agreement/.
# All intellectual property rights in and to the Panther SaaS, including any and all
# rights to access the Panther SaaS, are governed by the Panther Enterprise Subscription Agreement.
Loading

0 comments on commit 0ac2299

Please sign in to comment.