commit ae892791a9901a595bf1516152a7aadb2b64694c Author: lance Date: Tue Feb 26 01:20:27 2019 -0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fea943 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.venv +config.py +__pycache__ +.DS_Store +.idea +zappa_settings.json +*zip +*.egg-info +*pyc +build/ +dist/ diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..2447bb8 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Lance Allen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3a1b34 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# secretshare diff --git a/bin/cleanup b/bin/cleanup new file mode 100755 index 0000000..faca1b6 --- /dev/null +++ b/bin/cleanup @@ -0,0 +1,10 @@ +#!/bin/bash + +# Fetch all secrets +export secrets=$(aws secretsmanager list-secrets --query "SecretList[].Name[]" | jq -r ".[]") + +# Loop through and delete each one +while read -r line; do + echo "[+] Deleting ${line}"; + aws secretsmanager delete-secret --secret-id "${line}"; +done <<< "$secrets" diff --git a/bin/dev b/bin/dev new file mode 100755 index 0000000..815f47c --- /dev/null +++ b/bin/dev @@ -0,0 +1,7 @@ +#!/bin/bash + +source .venv/bin/activate +export FLASK_APP=secretshare/app.py +export FLASK_SECRETS=config.py +export FLASK_DEBUG=1 +flask run diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..24b4818 --- /dev/null +++ b/bin/setup @@ -0,0 +1,9 @@ +#!/bin/bash + +# This script assumes a lot - you should probably just set +# it up on your own rather than relying on this script + +rm -rf .venv +python3 -m venv .venv +source .venv/bin/activate +pip install . diff --git a/bin/test b/bin/test new file mode 100755 index 0000000..8d7f92a --- /dev/null +++ b/bin/test @@ -0,0 +1,10 @@ +#!/bin/bash + +echo -e "[+] Creating a secret" +curl -X POST 127.0.0.1:5000/secret/ \ + -H "Content-Type: application/json" \ + -d "{ + \"username\": \"$(whoami)\", + \"password\": \"$(openssl rand -base64 32)\", + \"message\": \"This is my secret password!\" +}" diff --git a/secretshare/__init__.py b/secretshare/__init__.py new file mode 100644 index 0000000..b627cd1 --- /dev/null +++ b/secretshare/__init__.py @@ -0,0 +1 @@ +from secretshare._version import __version__ diff --git a/secretshare/_version.py b/secretshare/_version.py new file mode 100644 index 0000000..a4e2017 --- /dev/null +++ b/secretshare/_version.py @@ -0,0 +1 @@ +__version__ = "0.1" diff --git a/secretshare/app.py b/secretshare/app.py new file mode 100644 index 0000000..b3d639e --- /dev/null +++ b/secretshare/app.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python + + +from flask import Flask, jsonify, request, make_response +from flask_restplus import Api, Resource, reqparse, fields +from secretshare import __version__ +from secretshare.library import secretsmanager + + +# Define Flask application +app = Flask(__name__) +app.config.from_envvar('FLASK_SECRETS') +api = Api(app, version=__version__, title=app.config['APP_NAME'], + description='Simple secret sharing API using AWS Secrets Manager' +) + +# Define models +secret_data = api.model('secret_data', { + 'username': fields.String, + 'password': fields.String, + 'message': fields.String, + 'expiration': fields.DateTime, +}) +response_data = api.inherit('response_data', secret_data, { + 'token': fields.String, + 'error_msg': fields.String, + 'error_id': fields.String +}) + +# Parse query strings/arguments +parser = reqparse.RequestParser() +parser.add_argument('token', type=str, help='Token provided when creating the secret') + + +@api.route('/secret/') +class Secrets(Resource): + """Represents available actions + for managing secrets on AWS Secrets + Manager. Can retrieve and create secrets. + """ + + @api.doc('retrieve', parser=parser) + @api.marshal_with(response_data, code=200, skip_none=True) + def get(self): + args = parser.parse_args() + + if args.get('token'): + secret_name = args.get('token') + secret = secretsmanager.Secret(secret_name=secret_name) + + if secret.exists and not secret.expired: + # If secret exists and not expired, return secret + return secret.retrieve(), 200 + else: + # If secret is expired or doesn't exist, return error + return { + 'error_msg': 'This secret is expired or does not exist.', + 'error_id': 'expired_secret' + }, 400 + else: + # If no query string provided, return error + return { + 'error_msg': 'No secret token provided.', + 'error_id': 'no_token' + }, 400 + + @api.doc('create') + @api.expect(secret_data, validate=True) + @api.marshal_with(response_data, code=201, skip_none=True) + def post(self): + if api.payload: + try: + secret = secretsmanager.Secret() + secret.create( + username=api.payload.get('username', ''), + password=api.payload.get('password', ''), + message=api.payload.get('message', ''), + expiration=api.payload.get('expiration', '') + ) + return {'token': secret.secret_name}, 201 + except ValueError as err: + return { + 'error_msg': 'Invalid expiration date', + 'error_id': err + }, 400 + else: + return { + 'error_msg': 'No secret JSON payload provided', + 'error_id': 'no_payload' + }, 400 + + +@app.errorhandler(404) +def not_found(error): + response = make_response(jsonify({ + 'error_msg': 'Route not found', + 'error_id': 'not_found'} + ), 404) + return response + + +if __name__ == '__main__': + app.run() diff --git a/secretshare/cleanup.py b/secretshare/cleanup.py new file mode 100644 index 0000000..dca9cc4 --- /dev/null +++ b/secretshare/cleanup.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python + + +from boto3 import client as boto3_client +from arrow import get as arrow_get +from arrow import utcnow as arrow_utcnow +from secretshare.library import secretsmanager + + +def purge_expired_secrets(): + """Purge all expired secrets. This is run as + a recurring Lambda function. + """ + client = boto3_client('secretsmanager') + all_secrets = secretsmanager.list_secrets(client) + for secret_data in all_secrets: + secret = secretsmanager.Secret() + secret.check_tags_expired(secret_data) + if secret.expired: + print(f"[+] Purging expired secret {secret_data['Name']}") + secretsmanager.delete_secret(client, secret_data['Name']) diff --git a/secretshare/config.example.py b/secretshare/config.example.py new file mode 100644 index 0000000..3eb11b6 --- /dev/null +++ b/secretshare/config.example.py @@ -0,0 +1,2 @@ +APP_NAME = "secretshare" +DEFAULT_HOURS = 1 diff --git a/secretshare/library/__init__.py b/secretshare/library/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/secretshare/library/secretsmanager.py b/secretshare/library/secretsmanager.py new file mode 100644 index 0000000..ec262fb --- /dev/null +++ b/secretshare/library/secretsmanager.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python + + +from boto3 import client as boto3_client +from json import loads as json_loads +from json import dumps as json_dumps +from arrow import get as arrow_get +from arrow import utcnow as arrow_utcnow +from flask import current_app as app +from secrets import token_urlsafe + + +class Secret(object): + """ + Secret objects represent a secret on + AWS Secrets Manager. Methods involve actions + you can perform on these items via the AWS API. + """ + + def __init__(self, secret_name=''): + self.secret_name = secret_name + + if self.secret_name: + self.check() + + + def check(self): + """Check if secret exists and if expired or not""" + + client = boto3_client('secretsmanager') + + try: + response = client.describe_secret( + SecretId=self.secret_name + ) + self.check_tags_expired(response) + self.exists = True + except: + self.exists = False + self.expired = None + + def check_tags_expired(self, json_data): + """Given a DescribeSecret JSON response and assess whether + the 'Expiration' tag shows the secret is expired or not + """ + + now = arrow_utcnow() + for tag in json_data['Tags']: + if 'Expiration' in tag.values(): + self.expiration = tag['Value'] + expiration_date = arrow_get(tag['Value']) + if (expiration_date - now).total_seconds() < 0: + # If delta is negative, we've passed expiration + self.expired = True + else: + self.expired = False + + return + + + def create(self, username, password, message, expiration=''): + """Create a secret""" + + now = arrow_utcnow() + client = boto3_client('secretsmanager') + self.secret_name = token_urlsafe(32) + self.username = str(username) + self.password = str(password) + self.message = str(message) + data_object = { + "username": self.username, + "password": self.password, + "message": self.message + } + + if not expiration: + # Set default time expiration + self.expiration = str(now.shift(hours=app.config['DEFAULT_HOURS'])) + else: + # Otherwise validate we have a good expiration date + try: + arrow_get(expiration) + except: + raise ValueError('invalid_datestamp') + + try: + assert (arrow_get(expiration) - now).total_seconds() > 0 + except: + raise ValueError('expired_datestamp') + + self.expiration = str(arrow_get(expiration)) + + response = client.create_secret( + Name=self.secret_name, + SecretString=json_dumps(data_object), + Tags=[ + { + 'Key': 'Expiration', + 'Value': self.expiration + }, + ] + ) + + return response + + + def retrieve(self): + """Retrieve a secret's value as dictionary object""" + + client = boto3_client('secretsmanager') + response = client.get_secret_value( + SecretId=self.secret_name + ) + secret_value = json_loads(response["SecretString"]) + secret_value['expiration'] = self.expiration + + return secret_value + + +# One-off functions used by cleanup Lambda + +def list_secrets(boto_client): + """List all secrets""" + next_token = "" + pagination_finished = False + secrets = [] + response = boto_client.list_secrets( + MaxResults=20 + ) + while not pagination_finished: + for secret in response['SecretList']: + secrets.append(secret) + if 'NextToken' in response: + next_token = response['NextToken'] + response = boto_client.list_secrets( + MaxResults=20, + NextToken=next_token + ) + else: + pagination_finished = True + + return secrets + +def delete_secret(boto_client, secret_name): + """Remove a secret""" + response = boto_client.delete_secret( + SecretId=secret_name, + ForceDeleteWithoutRecovery=True + ) + return response diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b938fb6 --- /dev/null +++ b/setup.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- + +from setuptools import setup, find_packages +from secretshare import __version__ + +NAME = "secretshare" +DESCRIPTION = "Flask application for sharing secrets using AWS Secrets Manager" +URL = "https://github.com/lalanza808/secretshare" +EMAIL = "lance@lzatech.org" +AUTHOR = "Lance Allen" +REQUIRES_PYTHON = ">=3.6.0" +VERSION = __version__ +REQUIRED = [ + "arrow==0.12.1", + "boto3==1.9.74", + "flask-restplus==0.12.1", + "Flask==1.0.2", + "zappa==0.47.1" +] +EXTRAS = { +} +TESTS = [ +] +SETUP = [ +] + +setup( + name=NAME, + version=VERSION, + description=DESCRIPTION, + author=AUTHOR, + author_email=EMAIL, + include_package_data=True, + extras_require=EXTRAS, + install_requires=REQUIRED, + setup_requires=SETUP, + tests_require=TESTS, + packages=find_packages(exclude=['ez_setup']), + zip_safe=False +) diff --git a/zappa_settings.example.json b/zappa_settings.example.json new file mode 100644 index 0000000..c7dba42 --- /dev/null +++ b/zappa_settings.example.json @@ -0,0 +1,30 @@ +{ + "dev": { + "app_function": "secretshare.app.app", + "aws_region": "us-west-2", + "profile_name": "default", + "project_name": "secretshare", + "log_level": "INFO", + "runtime": "python3.6", + "s3_bucket": "##CHANGEME##", + "environment_variables": { + "FLASK_SECRETS": "config.py" + }, + "events": [{ + "function": "secretshare.cleanup.purge_expired_secrets", + "expression": "rate(12 hours)" + }], + "extra_permissions": [{ + "Effect": "Allow", + "Action": [ + "secretsmanager:ListSecrets", + "secretsmanager:DescribeSecret", + "secretsmanager:DeleteSecret", + "secretsmanager:GetSecretValue", + "secretsmanager:CreateSecret", + "secretsmanager:TagResource" + ], + "Resource": "*" + }] + } +}