From 364818ed068d3852a6dcf50a9acda96a369d4614 Mon Sep 17 00:00:00 2001 From: Matthew Ford Date: Fri, 1 May 2026 13:42:09 -0700 Subject: [PATCH 1/4] Very first cut at adding an AWS Secrets record import. Commander will use the credentials in $HOME/.aws by default, and the user must provide the id of a shared folder to import secrets into: aws-secrets-import xAbCdEfGhIjK asi xAbCdEfGhIjK --region us-west-2 --- keepercommander/commands/aws_import.py | 300 +++++++++++++++++++++++++ keepercommander/commands/record.py | 6 + setup.cfg | 2 + 3 files changed, 308 insertions(+) create mode 100644 keepercommander/commands/aws_import.py diff --git a/keepercommander/commands/aws_import.py b/keepercommander/commands/aws_import.py new file mode 100644 index 000000000..37d28d625 --- /dev/null +++ b/keepercommander/commands/aws_import.py @@ -0,0 +1,300 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Dict[str, str] + """ + Parse the secret string into name/value pairs. + + Supported formats (in priority order): + 1. JSON object -> {"key": "value", ...} + 2. KEY=VALUE lines (one per line, shell-style) + 3. Fallback: store the whole string under a single field named "value" + """ + secret_string = (secret_string or '').strip() + if not secret_string: + return {} + + # Try JSON first + if secret_string.startswith('{'): + try: + obj = json.loads(secret_string) + if isinstance(obj, dict): + return {str(k): str(v) for k, v in obj.items()} + except (json.JSONDecodeError, ValueError): + pass + + # Try KEY=VALUE lines + pairs = {} + lines = secret_string.splitlines() + parsed_as_kv = False + for line in lines: + line = line.strip() + if not line or line.startswith('#'): + continue + if '=' in line: + key, _, val = line.partition('=') + pairs[key.strip()] = val.strip() + parsed_as_kv = True + else: + parsed_as_kv = False + break + + if parsed_as_kv and pairs: + return pairs + + # Fallback: single field + return {'value': secret_string} + + # ------------------------------------------------------------------ + # Keeper record builder + # ------------------------------------------------------------------ + + @staticmethod + def _build_record(title, fields, record_type='login'): + # type: (str, Dict[str, str], str) -> vault.TypedRecord + """ + Build a TypedRecord from a title and a dict of field name/value pairs. + + Known Keeper field types (text, login, password, url, …) are placed in + the typed *fields* list; everything else lands in *custom* fields. + """ + KNOWN_TYPED_FIELDS = {'login', 'password', 'url', 'email', 'text', 'note'} + + record = vault.TypedRecord() + record.type_name = record_type + record.title = title + + for field_name, field_value in fields.items(): + # Map common AWS naming conventions to Keeper field types + keeper_type = 'text' + if field_name.lower() in ('username', 'user', 'login'): + keeper_type = 'login' + elif field_name.lower() in ('password', 'pass', 'secret', 'secret_value'): + keeper_type = 'password' + elif field_name.lower() in ('url', 'endpoint', 'host'): + keeper_type = 'url' + + if keeper_type in KNOWN_TYPED_FIELDS: + typed_field = vault.TypedField.new_field(keeper_type, field_value, field_name) + record.fields.append(typed_field) + else: + custom_field = vault.TypedField.new_field('text', field_value, field_name) + record.custom.append(custom_field) + + return record + + # ------------------------------------------------------------------ + # Main entry point + # ------------------------------------------------------------------ + + def execute(self, params, **kwargs): # type: (KeeperParams, Any) -> None + folder_uid = kwargs.get('folder') or '' + access_key = kwargs.get('access_key') + secret_key = kwargs.get('secret_key') + region = kwargs.get('region') + dry_run = kwargs.get('dry_run', False) + record_type = kwargs.get('record_type') or 'login' + + if not folder_uid: + raise CommandError('aws-secrets-import', 'A shared folder UID is required.') + + # Verify the UID exists in the vault folder cache + if folder_uid not in params.folder_cache: + raise CommandError( + 'aws-secrets-import', + f'Folder UID "{folder_uid}" not found in your vault. ' + 'Use "list-sf" to find the correct shared folder UID.' + ) + + # Store credentials for use in get_client / _get_aws_kwargs + self._access_key = access_key or None + self._secret_key = secret_key or None + self._boto3_clients.clear() + + if access_key and not secret_key: + raise CommandError('aws-secrets-import', '--secret-key is required when --access-key is provided.') + if secret_key and not access_key: + raise CommandError('aws-secrets-import', '--access-key is required when --secret-key is provided.') + + # Fetch all secret metadata + logging.info('aws-secrets-import: listing secrets from AWS Secrets Manager…') + try: + secrets = self._list_secrets(region) + except Exception as exc: + raise CommandError('aws-secrets-import', f'Failed to list secrets from AWS: {exc}') + + if not secrets: + logging.warning('aws-secrets-import: no secrets found in AWS Secrets Manager.') + return + + logging.info('aws-secrets-import: found %d secret(s).', len(secrets)) + + created = 0 + skipped = 0 + + for secret_meta in secrets: + secret_name = secret_meta.get('Name') or '' + if not secret_name: + continue + + if dry_run: + print(f' [dry-run] would import: {secret_name}') + continue + + # Fetch the actual secret value + try: + secret_string = self._get_secret_value(secret_name, region) + except Exception as exc: + logging.warning('aws-secrets-import: skipping "%s" – could not retrieve value: %s', secret_name, exc) + skipped += 1 + continue + + fields = self._parse_secret_string(secret_string) + + record = self._build_record(secret_name, fields, record_type) + + try: + record_management.add_record_to_folder(params, record, folder_uid) + logging.info('aws-secrets-import: created record "%s"', secret_name) + created += 1 + except Exception as exc: + logging.warning('aws-secrets-import: failed to create record for "%s": %s', secret_name, exc) + skipped += 1 + + if not dry_run: + if created: + params.sync_data = True + print(f'aws-secrets-import: {created} record(s) created, {skipped} skipped.') diff --git a/keepercommander/commands/record.py b/keepercommander/commands/record.py index ba7bbf321..c14b02422 100644 --- a/keepercommander/commands/record.py +++ b/keepercommander/commands/record.py @@ -63,6 +63,8 @@ def handle_empty_result(fmt, message, filename=None): return None def register_commands(commands): + from . import aws_import + commands['aws-secrets-import'] = aws_import.AwsSecretsImportCommand() commands['search'] = SearchCommand() commands['get'] = RecordGetUidCommand() commands['rm'] = RecordRemoveCommand() @@ -100,6 +102,10 @@ def register_command_info(aliases, command_info): aliases['da'] = 'download-attachment' aliases['ua'] = 'upload-attachment' + from . import aws_import + aliases['asi'] = 'aws-secrets-import' + command_info[aws_import.aws_secrets_import_parser.prog] = aws_import.aws_secrets_import_parser.description + for p in [get_info_parser, search_parser, list_parser, list_sf_parser, list_team_parser, record_history_parser, shared_records_report_parser, record_edit.record_add_parser, record_edit.record_update_parser, record_edit.append_parser, record_edit.download_parser, diff --git a/setup.cfg b/setup.cfg index 732d48d9b..0c89a1483 100644 --- a/setup.cfg +++ b/setup.cfg @@ -61,6 +61,8 @@ keepercommander = resources/*, resources/email_templates/* test = pytest testfixtures +aws = + boto3>=1.26.0 email-sendgrid = sendgrid>=6.10.0 email-ses = From dc7a58139483ed169f2f8330f00daac586241b09 Mon Sep 17 00:00:00 2001 From: Matthew Ford Date: Fri, 1 May 2026 14:48:23 -0700 Subject: [PATCH 2/4] Added options to restrict which AWS secrets get imported: asi xAbCdEfGhIjK --name prod/db/password asi xAbCdEfGhIjK --name-starts-with prod/ --tags Team=payments asi xAbCdEfGhIjK --name-contains rds --tags Env=staging --dry-run asi xAbCdEfGhIjK --name-starts-with prod/ --name-ends-with /creds \ --name-contains database --tags Env=prod,Owner=platform --- keepercommander/commands/aws_import.py | 94 +++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/keepercommander/commands/aws_import.py b/keepercommander/commands/aws_import.py index 37d28d625..5fc363865 100644 --- a/keepercommander/commands/aws_import.py +++ b/keepercommander/commands/aws_import.py @@ -12,7 +12,7 @@ import argparse import json import logging -from typing import Dict, Optional, Any +from typing import Dict, List, Optional, Any, Tuple from .base import Command from .. import vault, record_management @@ -51,6 +51,32 @@ help='Keeper record type for imported records (default: login)' ) +filter_group = aws_secrets_import_parser.add_argument_group( + 'filters', + 'Restrict which secrets are imported. All provided filters must match (AND logic).' +) +filter_group.add_argument( + '--name', dest='filter_name', action='store', metavar='NAME', + help='Import only the secret with this exact name' +) +filter_group.add_argument( + '--name-starts-with', dest='filter_name_starts_with', action='store', metavar='PREFIX', + help='Import only secrets whose name starts with PREFIX' +) +filter_group.add_argument( + '--name-ends-with', dest='filter_name_ends_with', action='store', metavar='SUFFIX', + help='Import only secrets whose name ends with SUFFIX' +) +filter_group.add_argument( + '--name-contains', dest='filter_name_contains', action='store', metavar='SUBSTRING', + help='Import only secrets whose name contains SUBSTRING' +) +filter_group.add_argument( + '--tags', dest='filter_tags', action='store', metavar='KEY=VALUE[,KEY=VALUE,...]', + help='Import only secrets tagged with ALL specified key=value pairs ' + '(e.g. --tags Env=prod,Team=ops)' +) + class AwsSecretsImportCommand(Command): """Import AWS Secrets Manager secrets as Keeper records into a shared folder.""" @@ -216,6 +242,56 @@ def _build_record(title, fields, record_type='login'): return record + # ------------------------------------------------------------------ + # Filtering helpers + # ------------------------------------------------------------------ + + @staticmethod + def _parse_tags(tags_str): + # type: (str) -> List[Tuple[str, str]] + """Parse 'Key1=Val1,Key2=Val2' into [(Key1, Val1), (Key2, Val2)].""" + pairs = [] + for token in tags_str.split(','): + token = token.strip() + if not token: + continue + if '=' not in token: + raise CommandError( + 'aws-secrets-import', + f'Invalid --tags format: "{token}". Expected KEY=VALUE pairs separated by commas.' + ) + key, _, value = token.partition('=') + pairs.append((key.strip(), value.strip())) + return pairs + + @staticmethod + def _matches_filters(secret_meta, filter_name, filter_starts, filter_ends, + filter_contains, required_tags): + # type: (dict, Optional[str], Optional[str], Optional[str], Optional[str], List[Tuple[str, str]]) -> bool + """Return True only if the secret satisfies every provided filter.""" + name = secret_meta.get('Name') or '' + + if filter_name is not None and name != filter_name: + return False + if filter_starts is not None and not name.startswith(filter_starts): + return False + if filter_ends is not None and not name.endswith(filter_ends): + return False + if filter_contains is not None and filter_contains not in name: + return False + + if required_tags: + # AWS returns Tags as [{"Key": "...", "Value": "..."}, ...] + secret_tags = { + t.get('Key'): t.get('Value') + for t in (secret_meta.get('Tags') or []) + } + for tag_key, tag_value in required_tags: + if secret_tags.get(tag_key) != tag_value: + return False + + return True + # ------------------------------------------------------------------ # Main entry point # ------------------------------------------------------------------ @@ -228,6 +304,15 @@ def execute(self, params, **kwargs): # type: (KeeperParams, Any) -> None dry_run = kwargs.get('dry_run', False) record_type = kwargs.get('record_type') or 'login' + filter_name = kwargs.get('filter_name') or None + filter_starts = kwargs.get('filter_name_starts_with') or None + filter_ends = kwargs.get('filter_name_ends_with') or None + filter_contains = kwargs.get('filter_name_contains') or None + tags_str = kwargs.get('filter_tags') or '' + required_tags = [] # type: List[Tuple[str, str]] + if tags_str: + required_tags = self._parse_tags(tags_str) + if not folder_uid: raise CommandError('aws-secrets-import', 'A shared folder UID is required.') @@ -270,6 +355,13 @@ def execute(self, params, **kwargs): # type: (KeeperParams, Any) -> None if not secret_name: continue + if not self._matches_filters( + secret_meta, filter_name, filter_starts, filter_ends, + filter_contains, required_tags + ): + logging.debug('aws-secrets-import: skipping "%s" (filter mismatch)', secret_name) + continue + if dry_run: print(f' [dry-run] would import: {secret_name}') continue From b5d634afbc33c01393567b404ba655ddc4ebb689 Mon Sep 17 00:00:00 2001 From: Matthew Ford Date: Fri, 1 May 2026 15:37:19 -0700 Subject: [PATCH 3/4] Placate github security bot. --- keepercommander/commands/aws_import.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/keepercommander/commands/aws_import.py b/keepercommander/commands/aws_import.py index 5fc363865..5aeb2f5c0 100644 --- a/keepercommander/commands/aws_import.py +++ b/keepercommander/commands/aws_import.py @@ -351,39 +351,38 @@ def execute(self, params, **kwargs): # type: (KeeperParams, Any) -> None skipped = 0 for secret_meta in secrets: - secret_name = secret_meta.get('Name') or '' - if not secret_name: + skrt_name = secret_meta.get('Name') or '' + if not skrt_name: continue if not self._matches_filters( secret_meta, filter_name, filter_starts, filter_ends, filter_contains, required_tags ): - logging.debug('aws-secrets-import: skipping "%s" (filter mismatch)', secret_name) continue if dry_run: - print(f' [dry-run] would import: {secret_name}') + print(f' [dry-run] would import: {skrt_name}') continue # Fetch the actual secret value try: - secret_string = self._get_secret_value(secret_name, region) + secret_string = self._get_secret_value(skrt_name, region) except Exception as exc: - logging.warning('aws-secrets-import: skipping "%s" – could not retrieve value: %s', secret_name, exc) + logging.warning('aws-secrets-import: skipping "%s" – could not retrieve value: %s', skrt_name, exc) skipped += 1 continue fields = self._parse_secret_string(secret_string) - record = self._build_record(secret_name, fields, record_type) + record = self._build_record(skrt_name, fields, record_type) try: record_management.add_record_to_folder(params, record, folder_uid) - logging.info('aws-secrets-import: created record "%s"', secret_name) + logging.info('aws-secrets-import: created record "%s"', skrt_name) created += 1 except Exception as exc: - logging.warning('aws-secrets-import: failed to create record for "%s": %s', secret_name, exc) + logging.warning('aws-secrets-import: failed to create record for "%s": %s', skrt_name, exc) skipped += 1 if not dry_run: From 2ed31b44ac07ace7456ac2cf1fa5938264265703 Mon Sep 17 00:00:00 2001 From: Matthew Ford Date: Mon, 4 May 2026 09:13:26 -0700 Subject: [PATCH 4/4] Added README documentation for aws-secrets-import command. --- docs/aws-secrets-import.md | 266 +++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 docs/aws-secrets-import.md diff --git a/docs/aws-secrets-import.md b/docs/aws-secrets-import.md new file mode 100644 index 000000000..877682749 --- /dev/null +++ b/docs/aws-secrets-import.md @@ -0,0 +1,266 @@ +# `aws-secrets-import` — Import AWS Secrets Manager Secrets into Keeper + +The `aws-secrets-import` command reads every secret from AWS Secrets Manager and creates a corresponding Keeper record in a specified shared folder. Each secret's name becomes the record title; the secret's value is parsed into named fields on the record. + +- **Alias:** `asi` +- **Requires:** `boto3` — install with `pip install keeper-commander[aws]` + +--- + +## Table of Contents + +1. [Authentication](#authentication) +2. [Basic Usage](#basic-usage) +3. [Arguments & Flags](#arguments--flags) +4. [Filtering Secrets](#filtering-secrets) +5. [Secret Value Formats](#secret-value-formats) +6. [Keeper Record Structure](#keeper-record-structure) +7. [Examples](#examples) + +--- + +## Authentication + +The command resolves AWS credentials in the following order: + +1. **Explicit flags** — `--access-key` and `--secret-key` provided directly on the command line. +2. **boto3 credential chain** — if no explicit flags are given, the standard boto3 session is used, which checks (in order): + - Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, etc.) + - `~/.aws/credentials` and `~/.aws/config` + - IAM role attached to the running EC2 instance or ECS task + +In most production deployments you can omit the credential flags entirely and let the instance role or `~/.aws` configuration handle authentication. + +--- + +## Basic Usage + +``` +aws-secrets-import [options] +``` + +The only required argument is the **shared folder UID** — the unique identifier of the Keeper shared folder that will receive the imported records. Use `list-sf` inside Commander to find the UID for a folder: + +``` +My Vault> list-sf +``` + +--- + +## Arguments & Flags + +### Positional argument + +| Argument | Description | +|---|---| +| `folder` | **Required.** Shared folder UID to import secrets into. | + +### Credential flags + +| Flag | Description | +|---|---| +| `--access-key KEY` | AWS access key ID. Overrides the boto3 credential chain. | +| `--secret-key SECRET` | AWS secret access key. Required when `--access-key` is provided. | +| `--region REGION` | AWS region name (e.g. `us-east-1`). Uses the boto3 default if omitted. | + +### Behaviour flags + +| Flag | Description | +|---|---| +| `--record-type TYPE` | Keeper record type for imported records. Defaults to `login`. | +| `--dry-run` | List secrets that would be imported without creating any records. | + +### Filter flags + +All filter flags are optional and combine with AND logic — a secret must satisfy every provided filter to be imported. + +| Flag | Description | +|---|---| +| `--name NAME` | Import only the secret with this exact name. | +| `--name-starts-with PREFIX` | Import only secrets whose name starts with `PREFIX`. | +| `--name-ends-with SUFFIX` | Import only secrets whose name ends with `SUFFIX`. | +| `--name-contains SUBSTRING` | Import only secrets whose name contains `SUBSTRING`. | +| `--tags KEY=VALUE[,KEY=VALUE,...]` | Import only secrets tagged with **all** specified key/value pairs. | + +--- + +## Filtering Secrets + +Filters let you import a targeted subset of secrets without touching the rest. Every filter you specify must match for a secret to be imported. + +### Name filters + +Name filters operate on the full secret name as stored in AWS. + +```bash +# Exact name match +asi xAbCdEfGhIjK --name prod/database/primary + +# All secrets under the prod/ path +asi xAbCdEfGhIjK --name-starts-with prod/ + +# Secrets whose name ends with /credentials +asi xAbCdEfGhIjK --name-ends-with /credentials + +# Secrets whose name contains "rds" +asi xAbCdEfGhIjK --name-contains rds +``` + +Multiple name filters can be combined. Each one adds an additional requirement: + +```bash +# Must start with "prod/" AND contain "database" +asi xAbCdEfGhIjK --name-starts-with prod/ --name-contains database +``` + +### Tag filter + +The `--tags` flag accepts a comma-separated list of `KEY=VALUE` pairs. A secret is included only if it carries **all** of the specified tags with the exact values given. + +```bash +# Single tag requirement +asi xAbCdEfGhIjK --tags Env=prod + +# Multiple tag requirements (both must match) +asi xAbCdEfGhIjK --tags Env=prod,Team=payments +``` + +Tag keys and values are case-sensitive and must match the values stored in AWS exactly. + +### Combining filters + +All filter types can be used together in one command: + +```bash +asi xAbCdEfGhIjK \ + --name-starts-with prod/ \ + --name-ends-with /creds \ + --tags Env=prod,Owner=platform +``` + +A secret is imported only if it satisfies **every** filter listed. + +--- + +## Secret Value Formats + +When a secret is retrieved from AWS Secrets Manager, its `SecretString` is parsed into a set of named field values using the following rules, applied in priority order: + +### 1. JSON object + +If the secret string begins with `{` and is valid JSON representing an object, each key/value pair in the object becomes a separate field on the Keeper record. + +```json +{ + "username": "admin", + "password": "s3cur3P@ss!", + "host": "db.internal.example.com" +} +``` + +Results in three fields: `username`, `password`, and `host`. + +### 2. KEY=VALUE lines (shell-style) + +If the secret string is not JSON, the command attempts to parse it as newline-separated `KEY=VALUE` pairs (the same format used by `.env` files). Lines beginning with `#` and blank lines are ignored. + +``` +# Database credentials +username=admin +password=s3cur3P@ss! +host=db.internal.example.com +``` + +Results in three fields: `username`, `password`, and `host`. + +### 3. Fallback — plain string + +If the secret string cannot be parsed as JSON or as `KEY=VALUE` lines, the entire string is stored as a single field named `value`. + +``` +s3cur3P@ss! +``` + +Results in one field: `value = s3cur3P@ss!`. + +--- + +## Keeper Record Structure + +Each imported secret produces one **TypedRecord** in the target shared folder: + +- **Title** — the original AWS secret name (e.g. `prod/database/primary`). +- **Record type** — controlled by `--record-type` (default: `login`). + +### Field placement + +Parsed key/value pairs from the secret are mapped to Keeper field types before being placed on the record: + +| Parsed key (case-insensitive) | Keeper field type | Placement | +|---|---|---| +| `username`, `user`, `login` | `login` | Typed fields | +| `password`, `pass`, `secret`, `secret_value` | `password` | Typed fields | +| `url`, `endpoint`, `host` | `url` | Typed fields | +| anything else | `text` | Custom fields | + +Fields whose type matches a known Keeper typed field (`login`, `password`, `url`, `email`, `text`, `note`) are placed in the record's **typed fields** list. All other parsed keys are stored as **custom fields** with type `text`. + +--- + +## Examples + +### Import all secrets using ambient AWS credentials + +```bash +asi xAbCdEfGhIjK +``` + +Uses `~/.aws` credentials or the attached EC2/ECS instance role automatically. + +### Specify credentials and region explicitly + +```bash +asi xAbCdEfGhIjK \ + --access-key AKIAIOSFODNN7EXAMPLE \ + --secret-key wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \ + --region us-west-2 +``` + +### Preview what would be imported (dry run) + +```bash +asi xAbCdEfGhIjK --dry-run +``` + +Prints the name of each secret that passes all filters without creating any records. + +### Import only production secrets owned by the payments team + +```bash +asi xAbCdEfGhIjK --name-starts-with prod/ --tags Team=payments +``` + +### Import a single known secret + +```bash +asi xAbCdEfGhIjK --name prod/payments/stripe-api-key +``` + +### Import all RDS secrets in staging and store as `serverCredentials` records + +```bash +asi xAbCdEfGhIjK \ + --name-contains rds \ + --tags Env=staging \ + --record-type serverCredentials +``` + +### Dry-run a complex filter before committing + +```bash +asi xAbCdEfGhIjK \ + --name-starts-with prod/ \ + --name-ends-with /creds \ + --tags Env=prod,Owner=platform \ + --dry-run +```