From 5619b764d5ebb86f7a6374c54aa9e968997435a6 Mon Sep 17 00:00:00 2001 From: Mehmet Nuri Deveci <5735811+mndeveci@users.noreply.github.com> Date: Mon, 23 Nov 2020 13:07:12 -0800 Subject: [PATCH] feat: Lambda Code Signer Support (#2407) * Code Sign Integration (#217) * Release v1.0.0 (#2111) * feat: Use aws-sam-cli docker images (#2066) * Add Source for Docker Build Images (#2078) * chore: Bump AWS SAM CLI Version (#2079) * Version bump (#2080) * chore: Bump AWS SAM CLI Version * Change SAM CLI Version Number There is a conflict betweeen PyPi documentation which asks for the previous style https://packaging.python.org/guides/distributing-packages-using-setuptools/#pre-release-versioning and PEP 440 which proposes the style included in this change https://www.python.org/dev/peps/pep-0440/#pre-releases - our MSI build scripts failed on the pattern we were using before, this changes the pattern. * refactor: Build init.go with -s and -w flags to removed debug info (#2083) * refactor: Bake Rapid into image on the fly (#2100) * refactor: Bake Rapid into image on the fly * force chmod on init binary in container for windows * bake go debug bootstrap Co-authored-by: Jacob Fuss * chore: Bump version to RC2 (#2104) * Remove liblzma and libxslt from AL2 build images (#2109) Discovered a regression where on Ruby 2.7, the `nokogiri` dependency would build without errors, but would not run on local testing or on AWS Lambda itself. On further investigation, it appears that `nokogiri` can compile with or without `liblzma` present, but if it is present in the build enviornment (pre-change) and it is not present on the invoke environment (true in AL2 runtimes), you will experience runtime failures attempting to require `nokogiri`. I have been able to verify that with these changes, `nokogiri` builds correctly for Ruby 2.7 and runs both locally and on AWS Lambda. * Build output dots (#2112) * Use Low-Level Docker Client Allows us to stream dots as a progress heartbeat. Pending unit tests and black formatting. * Get make pr Passing Co-authored-by: Jacob Fuss <32497805+jfuss@users.noreply.github.com> Co-authored-by: Jacob Fuss * chore: Bump aws-lambda-builders and SAM CLI to 1.0.0 (#2116) * fix: Update Python3.8 debug entrypoint (#2119) * chore: readme update with screenshot (#2117) * chore: readme update with screenshot * chore: remove beta in the title Co-authored-by: Alex Wood * feature: Lambda Code Sign integration for SAM CLI * feature: Lambda Code Sign integration for SAM CLI (actual signing impl and unit tests) * Add details to print_deploy_args Add documentation for missing classes and methods * Update couple of prompts * Wording changes requested by UX & Docs Team Co-authored-by: Alex Wood Co-authored-by: Jacob Fuss <32497805+jfuss@users.noreply.github.com> Co-authored-by: Jacob Fuss Co-authored-by: Sriram Madapusi Vasudevan <3770774+sriram-mv@users.noreply.github.com> * - Update code signer param to align with tags and parameter-override params. - Added additional unit tests * chore: merge public develop with code signer changes * feat: Code Signer integration tests * add zip only if package needs to be signed * chore: bump SAM CLI version, update sam-translator dependency and tests with 1.31.0 Co-authored-by: Alex Wood Co-authored-by: Jacob Fuss <32497805+jfuss@users.noreply.github.com> Co-authored-by: Jacob Fuss Co-authored-by: Sriram Madapusi Vasudevan <3770774+sriram-mv@users.noreply.github.com> --- requirements/base.txt | 2 +- requirements/reproducible-linux.txt | 8 +- samcli/__init__.py | 2 +- samcli/cli/types.py | 80 +++++++- samcli/commands/_utils/options.py | 19 +- samcli/commands/deploy/code_signer_utils.py | 66 +++++++ samcli/commands/deploy/command.py | 8 + samcli/commands/deploy/deploy_context.py | 3 + samcli/commands/deploy/guided_config.py | 40 ++-- samcli/commands/deploy/guided_context.py | 65 ++++++- samcli/commands/deploy/utils.py | 12 +- samcli/commands/package/command.py | 12 +- samcli/commands/package/package_context.py | 9 +- samcli/lib/package/artifact_exporter.py | 32 +++- samcli/lib/package/code_signer.py | 146 +++++++++++++++ samcli/lib/package/s3_uploader.py | 13 ++ samcli/lib/providers/provider.py | 2 + samcli/lib/providers/sam_function_provider.py | 1 + ...h_basic_custom_domain_intrinsics_http.yaml | 1 + .../models/function_with_signing_profile.yaml | 24 +++ tests/integration/deploy/deploy_integ_base.py | 3 + .../integration/deploy/test_deploy_command.py | 66 +++++++ ...serverless-function-with-code-signing.yaml | 34 ++++ tests/unit/cli/test_types.py | 62 ++++++- .../commands/deploy/test_code_signer_utils.py | 111 +++++++++++ tests/unit/commands/deploy/test_command.py | 33 +++- .../commands/deploy/test_deploy_context.py | 1 + .../commands/deploy/test_guided_config.py | 6 +- .../commands/deploy/test_guided_context.py | 122 +++++++++++- .../commands/local/lib/test_local_lambda.py | 5 + .../local/lib/test_sam_function_provider.py | 42 +++++ tests/unit/commands/package/test_command.py | 7 + .../unit/commands/samconfig/test_samconfig.py | 6 + .../unit/lib/build_module/test_build_graph.py | 15 +- .../test_intrinsics_symbol_table.py | 4 +- .../lib/package/test_artifact_exporter.py | 136 ++++++++++---- tests/unit/lib/package/test_code_signer.py | 174 ++++++++++++++++++ tests/unit/lib/package/test_s3_uploader.py | 21 +++ .../local/docker/test_lambda_container.py | 3 +- 39 files changed, 1312 insertions(+), 84 deletions(-) create mode 100644 samcli/commands/deploy/code_signer_utils.py create mode 100644 samcli/lib/package/code_signer.py create mode 100644 tests/functional/commands/validate/lib/models/function_with_signing_profile.yaml create mode 100644 tests/integration/testdata/package/aws-serverless-function-with-code-signing.yaml create mode 100644 tests/unit/commands/deploy/test_code_signer_utils.py create mode 100644 tests/unit/lib/package/test_code_signer.py diff --git a/requirements/base.txt b/requirements/base.txt index 6325b800e6..0462402ffd 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -6,7 +6,7 @@ boto3~=1.14.23 jmespath~=0.10.0 PyYAML~=5.3 cookiecutter~=1.7.2 -aws-sam-translator==1.30.1 +aws-sam-translator==1.31.0 #docker minor version updates can include breaking changes. Auto update micro version only. docker~=4.2.0 dateparser~=0.7 diff --git a/requirements/reproducible-linux.txt b/requirements/reproducible-linux.txt index 679229e93f..2d6d758748 100644 --- a/requirements/reproducible-linux.txt +++ b/requirements/reproducible-linux.txt @@ -12,10 +12,10 @@ attrs==19.3.0 \ --hash=sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c \ --hash=sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72 \ # via jsonschema -aws-sam-translator==1.30.1 \ - --hash=sha256:1a1903fd1fda55bac5ba66b97b24c4b6b599973844e4ea45d0d1bcf381c22825 \ - --hash=sha256:22b89488e449a3f368ae8ab1eaacc1ebb190970de31d6492904252ab21117419 \ - --hash=sha256:bd9dbdf0773b52bb5bf652954bf47ab6bd5e2bd752255209207c8a4e52bb3735 \ +aws-sam-translator==1.31.0 \ + --hash=sha256:3a1d73d098161e60966b0d53bb310c98e4f66101688cce3d1697903643782d79 \ + --hash=sha256:65749571042e704027bbcaabe6dddd5d13ab54959c46e60c347918d6b85551fd \ + --hash=sha256:b87a61cffba8f29e4f1b5eb43a4abafdfb14b76aedc716517d48dbdf0b1f989a \ # via aws-sam-cli (setup.py) aws_lambda_builders==1.1.0 \ --hash=sha256:2b40a0003c2c05143e1aa816fed758c7d78f3e5c8e115be681aa2478f2655056 \ diff --git a/samcli/__init__.py b/samcli/__init__.py index 2f4bb945f9..0e3be610c8 100644 --- a/samcli/__init__.py +++ b/samcli/__init__.py @@ -2,4 +2,4 @@ SAM CLI version """ -__version__ = "1.11.0" +__version__ = "1.12.0" diff --git a/samcli/cli/types.py b/samcli/cli/types.py index 92413c53bc..3251db02ff 100644 --- a/samcli/cli/types.py +++ b/samcli/cli/types.py @@ -12,7 +12,6 @@ def _generate_match_regex(match_pattern, delim): - """ Creates a regex string based on a match pattern (also a regex) that is to be run on a string (which may contain escaped quotes) that is separated by delimiters. @@ -272,3 +271,82 @@ def _space_separated_key_value_parser(tag_value): return False, None tags_dict = {**tags_dict, **parsed_tag} return True, tags_dict + + +class SigningProfilesOptionType(click.ParamType): + """ + Custom parameter type to parse Signing Profile options + Example options could be; + - MyFunctionOrLayerToBeSigned=MySigningProfile + - MyFunctionOrLayerToBeSigned=MySigningProfile:MyProfileOwner + + See convert function docs for details + """ + + pattern = r"(?:(?: )([A-Za-z0-9\"]+)=(\"(?:\\.|[^\"\\]+)*\"|(?:\\.|[^ \"\\]+)+))" + + # Note: this is required, otherwise it is failing when running help + name = "" + + def convert(self, value, param, ctx): + """ + Converts given Signing Profile options to a dictionary where Function or Layer name would be key, + and signing profile details would be the value. + + Since this method is also been used when we are reading the config details from samconfig.toml, + If value is already a dictionary, don't need to do anything, just return it. + If value is an array, then each value will correspond to a function or layer config, and here it is parsed + and converted into a dictionary + """ + result = {} + + # Empty tuple + if value == ("",): + return result + + value = (value,) if isinstance(value, str) else value + for val in value: + val.strip() + # Add empty string to start of the string to help match `_pattern2` + val = " " + val + + signing_profiles = re.findall(self.pattern, val) + + # if no signing profiles found by regex, then fail + if not signing_profiles: + return self.fail( + f"{value} is not a valid code sign config, it should look like this 'MyFunction=SigningProfile'", + param, + ctx, + ) + + for function_name, param_value in signing_profiles: + (signer_profile_name, signer_profile_owner) = self._split_signer_profile_name_owner( + _unquote_wrapped_quotes(param_value) + ) + + # code signing requires profile name, if it is not present then fail + if not signer_profile_name: + return self.fail( + "Signer profile option has invalid format, it should look like this " + "MyFunction=MySigningProfile or MyFunction=MySigningProfile:MySigningProfileOwner", + param, + ctx, + ) + + result[_unquote_wrapped_quotes(function_name)] = { + "profile_name": signer_profile_name, + "profile_owner": signer_profile_owner, + } + + return result + + @staticmethod + def _split_signer_profile_name_owner(signing_profile): + equals_count = signing_profile.count(":") + + if equals_count > 1: + return None, None + if equals_count == 1: + return signing_profile.split(":") + return signing_profile, "" diff --git a/samcli/commands/_utils/options.py b/samcli/commands/_utils/options.py index c780431463..14d8b31c10 100644 --- a/samcli/commands/_utils/options.py +++ b/samcli/commands/_utils/options.py @@ -10,7 +10,7 @@ from click.types import FuncParamType from samcli.commands._utils.template import get_template_data, TemplateNotFoundException -from samcli.cli.types import CfnParameterOverridesType, CfnMetadataType, CfnTags +from samcli.cli.types import CfnParameterOverridesType, CfnMetadataType, CfnTags, SigningProfilesOptionType from samcli.commands._utils.custom_options.option_nargs import OptionNargs _TEMPLATE_OPTION_DEFAULT_VALUE = "template.[yaml|yml]" @@ -189,6 +189,23 @@ def no_progressbar_option(f): return no_progressbar_click_option()(f) +def signing_profiles_click_option(): + return click.option( + "--signing-profiles", + cls=OptionNargs, + type=SigningProfilesOptionType(), + default={}, + help="Optional. A string that contains Code Sign configuration parameters as " + "FunctionOrLayerNameToSign=SigningProfileName:SigningProfileOwner " + "Since signing profile owner is optional, it could also be written as " + "FunctionOrLayerNameToSign=SigningProfileName", + ) + + +def signing_profiles_option(f): + return signing_profiles_click_option()(f) + + def metadata_click_option(): return click.option( "--metadata", diff --git a/samcli/commands/deploy/code_signer_utils.py b/samcli/commands/deploy/code_signer_utils.py new file mode 100644 index 0000000000..ce0bf624fa --- /dev/null +++ b/samcli/commands/deploy/code_signer_utils.py @@ -0,0 +1,66 @@ +""" +Utilities for code signing process +""" + +import logging +from click import prompt, STRING + +from samcli.lib.providers.sam_function_provider import SamFunctionProvider + +LOG = logging.getLogger(__name__) + + +def prompt_profile_name(profile_name, start_bold, end_bold): + return prompt(f"\t{start_bold}Signing Profile Name{end_bold}", type=STRING, default=profile_name) + + +def prompt_profile_owner(profile_owner, start_bold, end_bold): + # click requires to have non None value for passing + if not profile_owner: + profile_owner = "" + + profile_owner = prompt( + f"\t{start_bold}Signing Profile Owner Account ID (optional){end_bold}", + type=STRING, + default=profile_owner, + show_default=len(profile_owner) > 0, + ) + + return profile_owner + + +def extract_profile_name_and_owner_from_existing(function_or_layer_name, signing_profiles): + profile_name = None + profile_owner = None + # extract any code sign config that is passed via command line + if function_or_layer_name in signing_profiles: + profile_name = signing_profiles[function_or_layer_name]["profile_name"] + profile_owner = signing_profiles[function_or_layer_name]["profile_owner"] + + return profile_name, profile_owner + + +def signer_config_per_function(parameter_overrides, template_dict): + functions_with_code_sign = set() + layers_with_code_sign = {} + + sam_functions = SamFunctionProvider(template_dict=template_dict, parameter_overrides=parameter_overrides) + + for sam_function in sam_functions.get_all(): + if sam_function.codesign_config_arn: + function_name = sam_function.name + LOG.debug("Found the following function with a code signing config %s", function_name) + functions_with_code_sign.add(function_name) + + if sam_function.layers: + for layer in sam_function.layers: + layer_name = layer.name + LOG.debug("Found following layers inside the function %s", layer_name) + if layer_name in layers_with_code_sign: + layers_with_code_sign[layer_name].add(function_name) + else: + functions_that_is_referring_to_function = set() + functions_that_is_referring_to_function.add(function_name) + layers_with_code_sign[layer_name] = functions_that_is_referring_to_function + + return functions_with_code_sign, layers_with_code_sign diff --git a/samcli/commands/deploy/command.py b/samcli/commands/deploy/command.py index ae1b591bd0..b3f0c18e66 100644 --- a/samcli/commands/deploy/command.py +++ b/samcli/commands/deploy/command.py @@ -16,6 +16,7 @@ no_progressbar_option, tags_override_option, template_click_option, + signing_profiles_option, ) from samcli.commands.deploy.utils import sanitize_parameter_overrides from samcli.lib.telemetry.metrics import track_command @@ -140,6 +141,7 @@ @notification_arns_override_option @tags_override_option @parameter_override_option +@signing_profiles_option @no_progressbar_option @capabilities_override_option @aws_creds_options @@ -166,6 +168,7 @@ def cli( metadata, guided, confirm_changeset, + signing_profiles, resolve_s3, config_file, config_env, @@ -193,6 +196,7 @@ def cli( confirm_changeset, ctx.region, ctx.profile, + signing_profiles, resolve_s3, config_file, config_env, @@ -220,6 +224,7 @@ def do_cli( confirm_changeset, region, profile, + signing_profiles, resolve_s3, config_file, config_env, @@ -240,6 +245,7 @@ def do_cli( profile=profile, confirm_changeset=confirm_changeset, capabilities=capabilities, + signing_profiles=signing_profiles, parameter_overrides=parameter_overrides, config_section=CONFIG_SECTION, config_env=config_env, @@ -269,6 +275,7 @@ def do_cli( on_deploy=True, region=guided_context.guided_region if guided else region, profile=profile, + signing_profiles=guided_context.signing_profiles if guided else signing_profiles, ) as package_context: package_context.run() @@ -292,5 +299,6 @@ def do_cli( region=guided_context.guided_region if guided else region, profile=profile, confirm_changeset=guided_context.confirm_changeset if guided else confirm_changeset, + signing_profiles=guided_context.signing_profiles if guided else signing_profiles, ) as deploy_context: deploy_context.run() diff --git a/samcli/commands/deploy/deploy_context.py b/samcli/commands/deploy/deploy_context.py index 1f328e7de3..fa3ae411fb 100644 --- a/samcli/commands/deploy/deploy_context.py +++ b/samcli/commands/deploy/deploy_context.py @@ -61,6 +61,7 @@ def __init__( region, profile, confirm_changeset, + signing_profiles, ): self.template_file = template_file self.stack_name = stack_name @@ -81,6 +82,7 @@ def __init__( self.s3_uploader = None self.deployer = None self.confirm_changeset = confirm_changeset + self.signing_profiles = signing_profiles def __enter__(self): return self @@ -129,6 +131,7 @@ def run(self): self.capabilities, self.parameter_overrides, self.confirm_changeset, + self.signing_profiles, ) return self.deploy( self.stack_name, diff --git a/samcli/commands/deploy/guided_config.py b/samcli/commands/deploy/guided_config.py index 59e2e3c7d8..e4729eb9fd 100644 --- a/samcli/commands/deploy/guided_config.py +++ b/samcli/commands/deploy/guided_config.py @@ -41,7 +41,9 @@ def read_config_showcase(self, config_file=None): if not config_sanity and samconfig.exists(): raise GuidedDeployFailedError(msg) - def save_config(self, parameter_overrides, config_env=DEFAULT_ENV, config_file=None, **kwargs): + def save_config( + self, parameter_overrides, config_env=DEFAULT_ENV, config_file=None, signing_profiles=None, **kwargs + ): ctx, samconfig = self.get_config_ctx(config_file) @@ -53,16 +55,8 @@ def save_config(self, parameter_overrides, config_env=DEFAULT_ENV, config_file=N if value: samconfig.put(cmd_names, self.section, key, value, env=config_env) - if parameter_overrides: - _params = [] - for key, value in parameter_overrides.items(): - if isinstance(value, dict): - if not value.get("Hidden"): - _params.append(f"{key}={self.quote_parameter_values(value.get('Value'))}") - else: - _params.append(f"{key}={self.quote_parameter_values(value)}") - if _params: - samconfig.put(cmd_names, self.section, "parameter_overrides", " ".join(_params), env=config_env) + self._save_parameter_overrides(cmd_names, config_env, parameter_overrides, samconfig) + self._save_signing_profiles(cmd_names, config_env, samconfig, signing_profiles) samconfig.flush() @@ -75,5 +69,29 @@ def save_config(self, parameter_overrides, config_env=DEFAULT_ENV, config_file=N "developerguide/serverless-sam-cli-config.html" ) + def _save_signing_profiles(self, cmd_names, config_env, samconfig, signing_profiles): + if signing_profiles: + _params = [] + for key, value in signing_profiles.items(): + if value.get("profile_owner", None): + signing_profile_with_owner = f"{value['profile_name']}:{value['profile_owner']}" + _params.append(f"{key}={self.quote_parameter_values(signing_profile_with_owner)}") + else: + _params.append(f"{key}={self.quote_parameter_values(value['profile_name'])}") + if _params: + samconfig.put(cmd_names, self.section, "signing_profiles", " ".join(_params), env=config_env) + + def _save_parameter_overrides(self, cmd_names, config_env, parameter_overrides, samconfig): + if parameter_overrides: + _params = [] + for key, value in parameter_overrides.items(): + if isinstance(value, dict): + if not value.get("Hidden"): + _params.append(f"{key}={self.quote_parameter_values(value.get('Value'))}") + else: + _params.append(f"{key}={self.quote_parameter_values(value)}") + if _params: + samconfig.put(cmd_names, self.section, "parameter_overrides", " ".join(_params), env=config_env) + def quote_parameter_values(self, parameter_value): return '"{}"'.format(parameter_value) diff --git a/samcli/commands/deploy/guided_context.py b/samcli/commands/deploy/guided_context.py index 9a284dbfd2..2d22d07200 100644 --- a/samcli/commands/deploy/guided_context.py +++ b/samcli/commands/deploy/guided_context.py @@ -11,6 +11,12 @@ from samcli.commands._utils.options import _space_separated_list_func_type from samcli.commands._utils.template import get_template_parameters, get_template_data +from samcli.commands.deploy.code_signer_utils import ( + signer_config_per_function, + extract_profile_name_and_owner_from_existing, + prompt_profile_name, + prompt_profile_owner, +) from samcli.commands.deploy.exceptions import GuidedDeployFailedError from samcli.commands.deploy.guided_config import GuidedConfig from samcli.commands.deploy.auth_utils import auth_per_resource @@ -33,6 +39,7 @@ def __init__( profile=None, confirm_changeset=None, capabilities=None, + signing_profiles=None, parameter_overrides=None, save_to_config=True, config_section=None, @@ -57,6 +64,7 @@ def __init__( self.guided_s3_prefix = None self.guided_region = None self.guided_profile = None + self.signing_profiles = signing_profiles self._capabilities = None self._parameter_overrides = None self.start_bold = "\033[1m" @@ -112,7 +120,9 @@ def guided_prompts(self, parameter_override_keys): type=FuncParamType(func=_space_separated_list_func_type), ) - self.prompt_authorization(sanitize_parameter_overrides(input_parameter_overrides)) + sanitized_parameter_overrides = sanitize_parameter_overrides(input_parameter_overrides) + self.prompt_authorization(sanitized_parameter_overrides) + self.prompt_code_signing_settings(sanitized_parameter_overrides) save_to_config = confirm( f"\t{self.start_bold}Save arguments to configuration file{self.end_bold}", default=True @@ -159,6 +169,58 @@ def prompt_authorization(self, parameter_overrides): if not auth_confirm: raise GuidedDeployFailedError(msg="Security Constraints Not Satisfied!") + def prompt_code_signing_settings(self, parameter_overrides): + (functions_with_code_sign, layers_with_code_sign) = signer_config_per_function( + parameter_overrides, get_template_data(self.template_file) + ) + + # if no function or layer definition found with code signing, skip it + if not functions_with_code_sign and not layers_with_code_sign: + LOG.debug("No function or layer definition found with code sign config, skipping") + return + + click.echo("\n\t#Found code signing configurations in your function definitions") + sign_functions = confirm( + f"\t{self.start_bold}Do you want to sign your code?{self.end_bold}", + default=True, + ) + + if not sign_functions: + LOG.debug("User skipped code signing, continuing rest of the process") + self.signing_profiles = None + return + + if not self.signing_profiles: + self.signing_profiles = {} + + click.echo("\t#Please provide signing profile details for the following functions & layers") + + for function_name in functions_with_code_sign: + (profile_name, profile_owner) = extract_profile_name_and_owner_from_existing( + function_name, self.signing_profiles + ) + + click.echo(f"\t#Signing profile details for function '{function_name}'") + profile_name = prompt_profile_name(profile_name, self.start_bold, self.end_bold) + profile_owner = prompt_profile_owner(profile_owner, self.start_bold, self.end_bold) + self.signing_profiles[function_name] = {"profile_name": profile_name, "profile_owner": profile_owner} + self.signing_profiles[function_name]["profile_owner"] = "" if not profile_owner else profile_owner + + for layer_name, functions_use_this_layer in layers_with_code_sign.items(): + (profile_name, profile_owner) = extract_profile_name_and_owner_from_existing( + layer_name, self.signing_profiles + ) + click.echo( + f"\t#Signing profile details for layer '{layer_name}', " + f"which is used by functions {functions_use_this_layer}" + ) + profile_name = prompt_profile_name(profile_name, self.start_bold, self.end_bold) + profile_owner = prompt_profile_owner(profile_owner, self.start_bold, self.end_bold) + self.signing_profiles[layer_name] = {"profile_name": profile_name, "profile_owner": profile_owner} + self.signing_profiles[layer_name]["profile_owner"] = "" if not profile_owner else profile_owner + + LOG.debug("Signing profile names and owners %s", self.signing_profiles) + def prompt_parameters( self, parameter_override_from_template, parameter_override_from_cmdline, start_bold, end_bold ): @@ -212,6 +274,7 @@ def run(self): profile=self.guided_profile, confirm_changeset=self.confirm_changeset, capabilities=self._capabilities, + signing_profiles=self.signing_profiles, ) def _get_parameter_value(self, parameter_key, parameter_properties, parameter_override_from_cmdline): diff --git a/samcli/commands/deploy/utils.py b/samcli/commands/deploy/utils.py index d475be65f2..cba9dae453 100644 --- a/samcli/commands/deploy/utils.py +++ b/samcli/commands/deploy/utils.py @@ -6,7 +6,9 @@ import click -def print_deploy_args(stack_name, s3_bucket, region, capabilities, parameter_overrides, confirm_changeset): +def print_deploy_args( + stack_name, s3_bucket, region, capabilities, parameter_overrides, confirm_changeset, signing_profiles +): """ Print a table of the values that are used during a sam deploy @@ -20,6 +22,7 @@ def print_deploy_args(stack_name, s3_bucket, region, capabilities, parameter_ove Deployment s3 bucket : aws-sam-cli-managed-default-samclisourcebucket-abcdef Capabilities : ["CAPABILITY_IAM"] Parameter overrides : {'MyParamater': '***', 'Parameter2': 'dd'} + Signing Profiles : {'MyFunction': 'ProfileName:ProfileOwner'} :param stack_name: Name of the stack used during sam deploy :param s3_bucket: Name of s3 bucket used for packaging code artifacts @@ -27,6 +30,7 @@ def print_deploy_args(stack_name, s3_bucket, region, capabilities, parameter_ove :param capabilities: Corresponding IAM capabilities to be used during the stack deploy. :param parameter_overrides: Cloudformation parameter overrides to be supplied based on the stack's template :param confirm_changeset: Prompt for changeset to be confirmed before going ahead with the deploy. + :param signing_profiles: Signing profile details which will be used to sign functions/layers :return: """ @@ -37,6 +41,11 @@ def print_deploy_args(stack_name, s3_bucket, region, capabilities, parameter_ove capabilities_string = json.dumps(capabilities) + _signing_profiles = {} + if signing_profiles: + for key, value in signing_profiles.items(): + _signing_profiles[key] = f"{value['profile_name']}:{value['profile_owner']}" + click.secho("\n\tDeploying with following values\n\t===============================", fg="yellow") click.echo(f"\tStack name : {stack_name}") click.echo(f"\tRegion : {region}") @@ -44,6 +53,7 @@ def print_deploy_args(stack_name, s3_bucket, region, capabilities, parameter_ove click.echo(f"\tDeployment s3 bucket : {s3_bucket}") click.echo(f"\tCapabilities : {capabilities_string}") click.echo(f"\tParameter overrides : {_parameters}") + click.echo(f"\tSigning Profiles : {signing_profiles}") click.secho("\nInitiating deployment\n=====================", fg="yellow") diff --git a/samcli/commands/package/command.py b/samcli/commands/package/command.py index 9d4a4bf644..7295a70aae 100644 --- a/samcli/commands/package/command.py +++ b/samcli/commands/package/command.py @@ -5,7 +5,12 @@ from samcli.cli.cli_config_file import configuration_option, TomlProvider from samcli.cli.main import pass_context, common_options, aws_creds_options -from samcli.commands._utils.options import metadata_override_option, template_click_option, no_progressbar_option +from samcli.commands._utils.options import ( + metadata_override_option, + template_click_option, + no_progressbar_option, + signing_profiles_option, +) from samcli.commands._utils.resources import resources_generator from samcli.lib.bootstrap.bootstrap import manage_stack from samcli.lib.telemetry.metrics import track_command, track_template_warnings @@ -87,6 +92,7 @@ def resources_and_properties_help_string(): "Do not use --s3-guided parameter with this option.", ) @metadata_override_option +@signing_profiles_option @no_progressbar_option @common_options @aws_creds_options @@ -104,6 +110,7 @@ def cli( force_upload, no_progressbar, metadata, + signing_profiles, resolve_s3, config_file, config_env, @@ -121,6 +128,7 @@ def cli( force_upload, no_progressbar, metadata, + signing_profiles, ctx.region, ctx.profile, resolve_s3, @@ -137,6 +145,7 @@ def do_cli( force_upload, no_progressbar, metadata, + signing_profiles, region, profile, resolve_s3, @@ -168,5 +177,6 @@ def do_cli( metadata=metadata, region=region, profile=profile, + signing_profiles=signing_profiles, ) as package_context: package_context.run() diff --git a/samcli/commands/package/package_context.py b/samcli/commands/package/package_context.py index 8cd91c3c15..71a8eea81d 100644 --- a/samcli/commands/package/package_context.py +++ b/samcli/commands/package/package_context.py @@ -25,6 +25,7 @@ from samcli.commands.package.exceptions import PackageFailedError from samcli.lib.package.artifact_exporter import Template +from samcli.lib.package.code_signer import CodeSigner from samcli.lib.package.s3_uploader import S3Uploader from samcli.lib.utils.botoconfig import get_boto_config_with_user_agent from samcli.yamlhelper import yaml_dump @@ -59,6 +60,7 @@ def __init__( region, profile, on_deploy=False, + signing_profiles=None, ): self.template_file = template_file self.s3_bucket = s3_bucket @@ -73,6 +75,8 @@ def __init__( self.profile = profile self.on_deploy = on_deploy self.s3_uploader = None + self.code_signer = None + self.signing_profiles = signing_profiles def __enter__(self): return self @@ -95,6 +99,9 @@ def run(self): # attach the given metadata to the artifacts to be uploaded self.s3_uploader.artifact_metadata = self.metadata + code_signer_client = boto3.client("signer") + self.code_signer = CodeSigner(code_signer_client, self.signing_profiles) + try: exported_str = self._export(self.template_file, self.use_json) @@ -110,7 +117,7 @@ def run(self): raise PackageFailedError(template_file=self.template_file, ex=str(ex)) from ex def _export(self, template_path, use_json): - template = Template(template_path, os.getcwd(), self.s3_uploader) + template = Template(template_path, os.getcwd(), self.s3_uploader, self.code_signer) exported_template = template.export() if use_json: diff --git a/samcli/lib/package/artifact_exporter.py b/samcli/lib/package/artifact_exporter.py index 60ed6733c8..69793d777e 100644 --- a/samcli/lib/package/artifact_exporter.py +++ b/samcli/lib/package/artifact_exporter.py @@ -110,7 +110,7 @@ def parse_s3_url(url, bucket_name_property="Bucket", object_key_property="Key", raise ValueError("URL given to the parse method is not a valid S3 url " "{0}".format(url)) -def upload_local_artifacts(resource_id, resource_dict, property_name, parent_dir, uploader): +def upload_local_artifacts(resource_id, resource_dict, property_name, parent_dir, uploader, extension=None): """ Upload local artifacts referenced by the property at given resource and return S3 URL of the uploaded object. It is the responsibility of callers @@ -130,6 +130,7 @@ def upload_local_artifacts(resource_id, resource_dict, property_name, parent_dir :param parent_dir: Resolve all relative paths with respect to this directory :param uploader: Method to upload files to S3 + :param extension: Extension of the uploaded artifact :return: S3 URL of the uploaded object :raise: ValueError if path is not a S3 URL or a local path @@ -153,7 +154,7 @@ def upload_local_artifacts(resource_id, resource_dict, property_name, parent_dir # Or, pointing to a folder. Zip the folder and upload if is_local_folder(local_path): - return zip_and_upload(local_path, uploader) + return zip_and_upload(local_path, uploader, extension) # Path could be pointing to a file. Upload the file if is_local_file(local_path): @@ -169,9 +170,9 @@ def resource_not_packageable(resource_dict): return False -def zip_and_upload(local_path, uploader): +def zip_and_upload(local_path, uploader, extension): with zip_folder(local_path) as (zip_file, md5_hash): - return uploader.upload_with_dedup(zip_file, precomputed_md5=md5_hash) + return uploader.upload_with_dedup(zip_file, precomputed_md5=md5_hash, extension=extension) @contextmanager @@ -257,8 +258,9 @@ class Resource: # up the file before uploading This is useful for Lambda functions. FORCE_ZIP = False - def __init__(self, uploader): + def __init__(self, uploader, code_signer): self.uploader = uploader + self.code_signer = code_signer def export(self, resource_id, resource_dict, parent_dir): if resource_dict is None: @@ -299,8 +301,20 @@ def do_export(self, resource_id, resource_dict, parent_dir): """ Default export action is to upload artifacts and set the property to S3 URL of the uploaded object + If code signing configuration is provided for function/layer, uploaded artifact + will be replaced by signed artifact location """ - uploaded_url = upload_local_artifacts(resource_id, resource_dict, self.PROPERTY_NAME, parent_dir, self.uploader) + # code signer only accepts files which has '.zip' extension in it + # so package artifact with '.zip' if it is required to be signed + should_sign_package = self.code_signer.should_sign_package(resource_id) + artifact_extension = "zip" if should_sign_package else None + uploaded_url = upload_local_artifacts( + resource_id, resource_dict, self.PROPERTY_NAME, parent_dir, self.uploader, artifact_extension + ) + if should_sign_package: + uploaded_url = self.code_signer.sign_package( + resource_id, uploaded_url, self.uploader.get_version_of_artifact(uploaded_url) + ) set_value_from_jmespath(resource_dict, self.PROPERTY_NAME, uploaded_url) @@ -499,7 +513,7 @@ def do_export(self, resource_id, resource_dict, parent_dir): property_name=self.PROPERTY_NAME, resource_id=resource_id, template_path=abs_template_path ) - exported_template_dict = Template(template_path, parent_dir, self.uploader).export() + exported_template_dict = Template(template_path, parent_dir, self.uploader, self.code_signer).export() exported_template_str = yaml_dump(exported_template_dict) @@ -593,6 +607,7 @@ def __init__( template_path, parent_dir, uploader, + code_signer, resources_to_export=frozenset(RESOURCES_EXPORT_LIST), metadata_to_export=frozenset(METADATA_EXPORT_LIST), ): @@ -613,6 +628,7 @@ def __init__( self.resources_to_export = resources_to_export self.metadata_to_export = metadata_to_export self.uploader = uploader + self.code_signer = code_signer def export_global_artifacts(self, template_dict): """ @@ -703,7 +719,7 @@ def export(self): continue # Export code resources - exporter = exporter_class(self.uploader) + exporter = exporter_class(self.uploader, self.code_signer) exporter.export(resource_id, resource_dict, self.template_dir) return self.template_dict diff --git a/samcli/lib/package/code_signer.py b/samcli/lib/package/code_signer.py new file mode 100644 index 0000000000..0408d7a948 --- /dev/null +++ b/samcli/lib/package/code_signer.py @@ -0,0 +1,146 @@ +""" +Client for initiate and monitor code signing jobs +""" + +import logging + +from samcli.commands.exceptions import UserException +from samcli.lib.package.artifact_exporter import parse_s3_url + +LOG = logging.getLogger(__name__) + + +class CodeSigningInitiationException(UserException): + """ + Raised when code signing job initiation fails + """ + + def __init__(self, msg): + self.msg = msg + message_fmt = f"Failed to initiate signing job: {msg}" + super().__init__(message=message_fmt) + + +class CodeSigningJobFailureException(UserException): + """ + Raised when code signing job is not completed successfully + """ + + def __init__(self, msg): + self.msg = msg + message_fmt = f"Failed to sign package: {msg}" + super().__init__(message=message_fmt) + + +class CodeSigner: + """ + Class to sign functions/layers with their signing config + """ + + def __init__(self, signer_client, signing_profiles): + self.signer_client = signer_client + self.signing_profiles = signing_profiles + + def should_sign_package(self, resource_id): + """ + Checks whether given resource has code sign config, + True: if resource has code sign config + False: if resource doesn't have code sign config + """ + return bool(self.signing_profiles and resource_id in self.signing_profiles) + + def sign_package(self, resource_id, s3_url, s3_version): + """ + Signs artifact which is named with resource_id, its location is s3_url + and its s3 object version is s3_version + """ + # extract code signing config for the resource + signing_profile_for_resource = self.signing_profiles[resource_id] + profile_name = signing_profile_for_resource["profile_name"] + profile_owner = signing_profile_for_resource["profile_owner"] + + # parse given s3 url, and extract bucket and object key + parsed_s3_url = parse_s3_url(s3_url) + s3_bucket = parsed_s3_url["Bucket"] + s3_key = parsed_s3_url["Key"] + s3_target_prefix = s3_key.rsplit("/", 1)[0] + "/signed_" + + LOG.debug( + "Initiating signing job with bucket:%s key:%s version:%s prefix:%s profile name:%s profile owner:%s", + s3_bucket, + s3_key, + s3_version, + s3_target_prefix, + profile_name, + profile_owner, + ) + + # initiate and wait for signing job to finish + code_sign_job_id = self._initiate_code_signing( + profile_name, profile_owner, s3_bucket, s3_key, s3_target_prefix, s3_version + ) + self._wait_for_signing_job_to_complete(code_sign_job_id) + + try: + code_sign_job_result = self.signer_client.describe_signing_job(jobId=code_sign_job_id) + except Exception as e: + LOG.error("Checking the result of the code signing job failed %s", code_sign_job_id, exc_info=e) + raise CodeSigningJobFailureException(f"Signing job has failed status {code_sign_job_id}") from e + + # check if code sign job result status is Succeeded, fail otherwise + if code_sign_job_result and code_sign_job_result.get("status") == "Succeeded": + signed_object_result = code_sign_job_result.get("signedObject", {}).get("s3", {}) + LOG.info( + "Package has successfully signed into the location %s/%s", + signed_object_result.get("bucketName"), + signed_object_result.get("key"), + ) + signed_package_location = code_sign_job_result["signedObject"]["s3"]["key"] + return f"s3://{s3_bucket}/{signed_package_location}" + + LOG.error("Failed to sign the package, result: %s", code_sign_job_result) + raise CodeSigningJobFailureException(f"Signing job not succeeded {code_sign_job_id}") + + def _wait_for_signing_job_to_complete(self, code_sign_job_id): + """ + Creates a waiter object to wait signing job to complete + Checks job status for every 5 second + """ + try: + waiter = self.signer_client.get_waiter("successful_signing_job") + waiter.wait(jobId=code_sign_job_id, WaiterConfig={"Delay": 5}) + except Exception as e: + LOG.error("Checking status of code signing job failed %s", code_sign_job_id, exc_info=e) + raise CodeSigningJobFailureException(f"Signing job failed {code_sign_job_id}") from e + + def _initiate_code_signing(self, profile_name, profile_owner, s3_bucket, s3_key, s3_target_prefix, s3_version): + """ + Initiates code signing job and returns the initiated jobId + Raises exception if initiation fails + """ + try: + param_source = {"s3": {"bucketName": s3_bucket, "key": s3_key, "version": s3_version}} + param_destination = {"s3": {"bucketName": s3_bucket, "prefix": s3_target_prefix}} + + # start_signing_job doesn't accept default value for owner + # for that reason check if owner is valid + if profile_owner: + sign_response = self.signer_client.start_signing_job( + source=param_source, + destination=param_destination, + profileName=profile_name, + profileOwner=profile_owner, + ) + else: + sign_response = self.signer_client.start_signing_job( + source=param_source, + destination=param_destination, + profileName=profile_name, + ) + signing_job_id = sign_response.get("jobId") + LOG.info("Initiated code signing job %s", signing_job_id) + code_sign_job_id = signing_job_id + except Exception as e: + LOG.error("Initiating job signing job has failed", exc_info=e) + raise CodeSigningInitiationException("Initiating job signing job has failed") from e + return code_sign_job_id diff --git a/samcli/lib/package/s3_uploader.py b/samcli/lib/package/s3_uploader.py index 8508ba187c..1a78c40fef 100644 --- a/samcli/lib/package/s3_uploader.py +++ b/samcli/lib/package/s3_uploader.py @@ -28,6 +28,7 @@ from boto3.s3 import transfer from samcli.commands.package.exceptions import NoSuchBucketError, BucketNotSpecifiedError +from samcli.lib.package.artifact_exporter import parse_s3_url from samcli.lib.utils.hash import file_checksum LOG = logging.getLogger(__name__) @@ -169,6 +170,18 @@ def to_path_style_s3_url(self, key, version=None): return result + def get_version_of_artifact(self, s3_url): + """ + Returns version information of the S3 object that is given as S3 URL + """ + parsed_s3_url = parse_s3_url(s3_url) + s3_bucket = parsed_s3_url["Bucket"] + s3_key = parsed_s3_url["Key"] + s3_object_tagging = self.s3.get_object_tagging(Bucket=s3_bucket, Key=s3_key) + LOG.debug("S3 Object (%s) tagging information %s", s3_url, s3_object_tagging) + s3_object_version_id = s3_object_tagging["VersionId"] + return s3_object_version_id + class ProgressPercentage: # This class was copied directly from S3Transfer docs diff --git a/samcli/lib/providers/provider.py b/samcli/lib/providers/provider.py index d24056c032..822c5647fe 100644 --- a/samcli/lib/providers/provider.py +++ b/samcli/lib/providers/provider.py @@ -40,6 +40,8 @@ "events", # Metadata "metadata", + # Code Signing config ARN + "codesign_config_arn", ], ) diff --git a/samcli/lib/providers/sam_function_provider.py b/samcli/lib/providers/sam_function_provider.py index 39ae38e13d..9a137d4cfb 100644 --- a/samcli/lib/providers/sam_function_provider.py +++ b/samcli/lib/providers/sam_function_provider.py @@ -229,6 +229,7 @@ def _build_function_configuration(name, codeuri, resource_properties, layers): events=resource_properties.get("Events"), layers=layers, metadata=resource_properties.get("Metadata", None), + codesign_config_arn=resource_properties.get("CodeSigningConfigArn", None), ) @staticmethod diff --git a/tests/functional/commands/validate/lib/models/api_with_basic_custom_domain_intrinsics_http.yaml b/tests/functional/commands/validate/lib/models/api_with_basic_custom_domain_intrinsics_http.yaml index c03fac389d..b445a3bbbf 100644 --- a/tests/functional/commands/validate/lib/models/api_with_basic_custom_domain_intrinsics_http.yaml +++ b/tests/functional/commands/validate/lib/models/api_with_basic_custom_domain_intrinsics_http.yaml @@ -53,6 +53,7 @@ Resources: Type: AWS::Serverless::HttpApi Properties: StageName: Prod + DisableExecuteApiEndpoint: False Domain: DomainName: !Sub 'example-${AWS::Region}.com' CertificateArn: !Ref MyDomainCert diff --git a/tests/functional/commands/validate/lib/models/function_with_signing_profile.yaml b/tests/functional/commands/validate/lib/models/function_with_signing_profile.yaml new file mode 100644 index 0000000000..09a0323670 --- /dev/null +++ b/tests/functional/commands/validate/lib/models/function_with_signing_profile.yaml @@ -0,0 +1,24 @@ +Resources: + + FunctionWithSigningProfile: + Type: AWS::Serverless::Function + Properties: + CodeUri: s3://sam-demo-bucket/member_portal.zip + Handler: index.gethtml + Runtime: nodejs12.x + CodeSigningConfigArn: !Ref MySignedFunctionCodeSigningConfig + + MySignedFunctionCodeSigningConfig: + Type: AWS::Lambda::CodeSigningConfig + Properties: + Description: "Code Signing for MySignedLambdaFunction" + AllowedPublishers: + SigningProfileVersionArns: + - !GetAtt SigningProfile.ProfileVersionArn + CodeSigningPolicies: + UntrustedArtifactOnDeployment: "Enforce" + + SigningProfile: + Type: AWS::Signer::SigningProfile + Properties: + PlatformId: AWSLambda-SHA384-ECDSA \ No newline at end of file diff --git a/tests/integration/deploy/deploy_integ_base.py b/tests/integration/deploy/deploy_integ_base.py index 3b5241b848..d58a04bcc3 100644 --- a/tests/integration/deploy/deploy_integ_base.py +++ b/tests/integration/deploy/deploy_integ_base.py @@ -42,6 +42,7 @@ def get_deploy_command_list( guided=False, resolve_s3=False, config_file=None, + signing_profiles=None, ): command_list = [self.base_command(), "deploy"] @@ -89,6 +90,8 @@ def get_deploy_command_list( command_list = command_list + ["--resolve-s3"] if config_file: command_list = command_list + ["--config-file", str(config_file)] + if signing_profiles: + command_list = command_list + ["--signing-profiles", str(signing_profiles)] return command_list diff --git a/tests/integration/deploy/test_deploy_command.py b/tests/integration/deploy/test_deploy_command.py index 5ad709624a..c651b494f4 100644 --- a/tests/integration/deploy/test_deploy_command.py +++ b/tests/integration/deploy/test_deploy_command.py @@ -25,6 +25,27 @@ @skipIf(SKIP_DEPLOY_TESTS, "Skip deploy tests in CI/CD only") class TestDeploy(PackageIntegBase, DeployIntegBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # create signing profile which will be used for code signing tests + signer_client = boto3.client("signer") + + cls.signing_profile_name = str(uuid.uuid4().hex) + put_signing_profile_result = signer_client.put_signing_profile( + profileName=cls.signing_profile_name, platformId="AWSLambda-SHA384-ECDSA" + ) + cls.signing_profile_version_arn = put_signing_profile_result.get("profileVersionArn") + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + + # cancel signing profile after all tests completes + signer_client = boto3.client("signer") + signer_client.cancel_signing_profile(profileName=cls.signing_profile_name) + def setUp(self): self.cf_client = boto3.client("cloudformation") self.sns_arn = os.environ.get("AWS_SNS") @@ -581,6 +602,51 @@ def test_deploy_with_invalid_config(self, template_file, config_file): self.assertEqual(deploy_process_execute.process.returncode, 1) self.assertIn("Error reading configuration: Unexpected character", str(deploy_process_execute.stderr)) + @parameterized.expand([(True, True, True), (False, True, False), (False, False, True), (True, False, True)]) + def test_deploy_with_code_signing_params(self, should_sign, should_enforce, will_succeed): + """ + Signed function with UntrustedArtifactOnDeployment = Enforced config should succeed + Signed function with UntrustedArtifactOnDeployment = Warn config should succeed + Unsigned function with UntrustedArtifactOnDeployment = Enforce config should fail + Unsigned function with UntrustedArtifactOnDeployment = Warn config should succeed + """ + template_path = self.test_data_path.joinpath("aws-serverless-function-with-code-signing.yaml") + stack_name = self._method_to_stack_name(self.id()) + signing_profile_version_arn = TestDeploy.signing_profile_version_arn + signing_profile_name = TestDeploy.signing_profile_name + self.stack_names.append(stack_name) + + signing_profiles_param = None + if should_sign: + signing_profiles_param = f"HelloWorldFunctionWithCsc={signing_profile_name}" + + enforce_param = "Warn" + if should_enforce: + enforce_param = "Enforce" + + # Package and Deploy in one go without confirming change set. + deploy_command_list = self.get_deploy_command_list( + template_file=template_path, + stack_name=stack_name, + capabilities="CAPABILITY_IAM", + s3_prefix="integ_deploy", + s3_bucket=self.s3_bucket.name, + force_upload=True, + notification_arns=self.sns_arn, + kms_key_id=self.kms_key, + tags="integ=true clarity=yes foo_bar=baz", + signing_profiles=signing_profiles_param, + parameter_overrides=f"SigningProfileVersionArn={signing_profile_version_arn} " + f"UntrustedArtifactOnDeployment={enforce_param}", + ) + + deploy_process_execute = run_command(deploy_command_list) + + if will_succeed: + self.assertEqual(deploy_process_execute.process.returncode, 0) + else: + self.assertEqual(deploy_process_execute.process.returncode, 1) + def _method_to_stack_name(self, method_name): """Method expects method name which can be a full path. Eg: test.integration.test_deploy_command.method_name""" method_name = method_name.split(".")[-1] diff --git a/tests/integration/testdata/package/aws-serverless-function-with-code-signing.yaml b/tests/integration/testdata/package/aws-serverless-function-with-code-signing.yaml new file mode 100644 index 0000000000..a6830793b4 --- /dev/null +++ b/tests/integration/testdata/package/aws-serverless-function-with-code-signing.yaml @@ -0,0 +1,34 @@ +AWSTemplateFormatVersion : '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: A hello world application with code signing config + +Parameters: + SigningProfileVersionArn: + Type: String + Description: Give previously created signing profile version ARN which will be used to validate signature of the package + UntrustedArtifactOnDeployment: + Type: String + Description: Value for UntrustedArtifactOnDeployment for AWS::Lambda::CodeSigningConfig resource + AllowedValues: + - Enforce + - Warn + +Resources: + HelloWorldFunctionWithCsc: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + Runtime: python3.7 + CodeUri: . + Timeout: 600 + CodeSigningConfigArn: !Ref HelloWorldFunctionCodeSigningConfig + + HelloWorldFunctionCodeSigningConfig: + Type: AWS::Lambda::CodeSigningConfig + Properties: + Description: "Code Signing for MySignedLambdaFunction" + AllowedPublishers: + SigningProfileVersionArns: + - !Ref SigningProfileVersionArn + CodeSigningPolicies: + UntrustedArtifactOnDeployment: !Ref UntrustedArtifactOnDeployment diff --git a/tests/unit/cli/test_types.py b/tests/unit/cli/test_types.py index 23e148a230..809be1b932 100644 --- a/tests/unit/cli/test_types.py +++ b/tests/unit/cli/test_types.py @@ -2,7 +2,7 @@ from unittest.mock import Mock, ANY from parameterized import parameterized -from samcli.cli.types import CfnParameterOverridesType, CfnTags +from samcli.cli.types import CfnParameterOverridesType, CfnTags, SigningProfilesOptionType from samcli.cli.types import CfnMetadataType @@ -225,3 +225,63 @@ def test_must_fail_on_invalid_format(self, input): def test_successful_parsing(self, input, expected): result = self.param_type.convert(input, None, None) self.assertEqual(result, expected, msg="Failed with Input = " + str(input)) + + +class TestCodeSignOptionType(TestCase): + def setUp(self): + self.param_type = SigningProfilesOptionType() + + @parameterized.expand( + [ + # Just a string + ("some string"), + # Wrong notation + ("a=b::"), + ("ab::"), + ("a=b::c"), + ("=b"), + ("=b:c"), + ("a=:c"), + ] + ) + def test_must_fail_on_invalid_format(self, input): + self.param_type.fail = Mock() + self.param_type.convert(input, "param", "ctx") + + self.param_type.fail.assert_called_with(ANY, "param", "ctx") + + @parameterized.expand( + [ + (("a=b",), {"a": {"profile_name": "b", "profile_owner": ""}}), + ( + ("a=b", "c=d"), + {"a": {"profile_name": "b", "profile_owner": ""}, "c": {"profile_name": "d", "profile_owner": ""}}, + ), + (("a=b:",), {"a": {"profile_name": "b", "profile_owner": ""}}), + (("a=b:c",), {"a": {"profile_name": "b", "profile_owner": "c"}}), + ( + ("a=b:c", "d=e:f"), + {"a": {"profile_name": "b", "profile_owner": "c"}, "d": {"profile_name": "e", "profile_owner": "f"}}, + ), + ( + ("a=b:c", "d=e"), + {"a": {"profile_name": "b", "profile_owner": "c"}, "d": {"profile_name": "e", "profile_owner": ""}}, + ), + ( + ("a=b:", "d=e"), + {"a": {"profile_name": "b", "profile_owner": ""}, "d": {"profile_name": "e", "profile_owner": ""}}, + ), + ( + "a=b:c d=e", + {"a": {"profile_name": "b", "profile_owner": "c"}, "d": {"profile_name": "e", "profile_owner": ""}}, + ), + ( + 'a="b:c" d="e"', + {"a": {"profile_name": "b", "profile_owner": "c"}, "d": {"profile_name": "e", "profile_owner": ""}}, + ), + (("",), {}), + ] + ) + def test_successful_parsing(self, input, expected): + result = self.param_type.convert(input, None, None) + self.assertEqual(result, expected, msg="Failed with Input = " + str(input)) diff --git a/tests/unit/commands/deploy/test_code_signer_utils.py b/tests/unit/commands/deploy/test_code_signer_utils.py new file mode 100644 index 0000000000..2ffdd26c68 --- /dev/null +++ b/tests/unit/commands/deploy/test_code_signer_utils.py @@ -0,0 +1,111 @@ +from collections import OrderedDict +from unittest import TestCase + +from samcli.commands.deploy.code_signer_utils import ( + extract_profile_name_and_owner_from_existing, + signer_config_per_function, +) + + +class TestCodeSignerUtils(TestCase): + def test_extract_profile_name_and_owner_from_existing(self): + given_function_name = "MyFunction" + given_profile_name = "MyProfile" + given_profile_owner = "MyProfileOwner" + given_code_signing_config = { + given_function_name: {"profile_name": given_profile_name, "profile_owner": given_profile_owner} + } + + (profile_name, profile_owner) = extract_profile_name_and_owner_from_existing( + given_function_name, given_code_signing_config + ) + + self.assertEqual(profile_name, given_profile_name) + self.assertEqual(profile_owner, given_profile_owner) + + def test_signer_config_per_function(self): + function_name_1 = "HelloWorldFunction1" + function_name_2 = "HelloWorldFunction2" + layer_name = "HelloWorldFunctionLayer" + template_dict = { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Description": "\nSample SAM Template for Tests\n", + "Globals": OrderedDict([("Function", OrderedDict([("Timeout", 3)]))]), + "Resources": OrderedDict( + [ + ( + function_name_1, + OrderedDict( + [ + ("Type", "AWS::Serverless::Function"), + ( + "Properties", + OrderedDict( + [ + ("CodeUri", "HelloWorldFunction"), + ("Handler", "app.lambda_handler"), + ("Runtime", "python3.7"), + ("CodeSigningConfigArn", "MyCodeSigningConfigArn"), + ( + "Layers", + [ + OrderedDict([("Ref", layer_name)]), + ], + ), + ] + ), + ), + ] + ), + ), + ( + function_name_2, + OrderedDict( + [ + ("Type", "AWS::Serverless::Function"), + ( + "Properties", + OrderedDict( + [ + ("CodeUri", "HelloWorldFunction2"), + ("Handler", "app.lambda_handler2"), + ("Runtime", "python3.7"), + ("CodeSigningConfigArn", "MyCodeSigningConfigArn"), + ( + "Layers", + [ + OrderedDict([("Ref", layer_name)]), + ], + ), + ] + ), + ), + ] + ), + ), + ( + layer_name, + OrderedDict( + [ + ("Type", "AWS::Serverless::LayerVersion"), + ( + "Properties", + OrderedDict( + [ + ("LayerName", "dependencies"), + ("ContentUri", "dependencies/"), + ("CompatibleRuntimes", ["python3.7"]), + ] + ), + ), + ] + ), + ), + ] + ), + } + (functions_with_code_sign, layers_with_code_sign) = signer_config_per_function({}, template_dict) + + self.assertEqual(functions_with_code_sign, {function_name_1, function_name_2}) + self.assertEqual(layers_with_code_sign, {layer_name: {function_name_1, function_name_2}}) diff --git a/tests/unit/commands/deploy/test_command.py b/tests/unit/commands/deploy/test_command.py index bb54a32333..215d6d8260 100644 --- a/tests/unit/commands/deploy/test_command.py +++ b/tests/unit/commands/deploy/test_command.py @@ -44,6 +44,7 @@ def setUp(self): self.resolve_s3 = False self.config_env = "mock-default-env" self.config_file = "mock-default-filename" + self.signing_profiles = None MOCK_SAM_CONFIG.reset_mock() @patch("samcli.commands.package.command.click") @@ -76,6 +77,7 @@ def test_all_args(self, mock_deploy_context, mock_deploy_click, mock_package_con metadata=self.metadata, guided=self.guided, confirm_changeset=self.confirm_changeset, + signing_profiles=self.signing_profiles, resolve_s3=self.resolve_s3, config_env=self.config_env, config_file=self.config_file, @@ -99,6 +101,7 @@ def test_all_args(self, mock_deploy_context, mock_deploy_click, mock_package_con region=self.region, profile=self.profile, confirm_changeset=self.confirm_changeset, + signing_profiles=self.signing_profiles, ) context_mock.run.assert_called_with() @@ -112,6 +115,7 @@ def test_all_args(self, mock_deploy_context, mock_deploy_click, mock_package_con @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.get_template_parameters") @patch("samcli.commands.deploy.guided_context.get_template_data") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") @patch.object(GuidedConfig, "get_config_ctx", MagicMock(return_value=(None, get_mock_sam_config()))) @patch("samcli.commands.deploy.guided_context.prompt") @patch("samcli.commands.deploy.guided_context.confirm") @@ -119,6 +123,7 @@ def test_all_args_guided_no_to_authorization_confirmation_prompt( self, mock_confirm, mock_prompt, + mock_signer_config_per_function, mock_get_template_data, mock_get_template_parameters, mockauth_per_resource, @@ -149,6 +154,7 @@ def test_all_args_guided_no_to_authorization_confirmation_prompt( } mock_managed_stack.return_value = "managed-s3-bucket" + mock_signer_config_per_function.return_value = ({}, {}) with patch.object(GuidedConfig, "save_config", MagicMock(return_value=True)) as mock_save_config: with self.assertRaises(GuidedDeployFailedError): @@ -173,6 +179,7 @@ def test_all_args_guided_no_to_authorization_confirmation_prompt( metadata=self.metadata, guided=True, confirm_changeset=True, + signing_profiles=self.signing_profiles, resolve_s3=self.resolve_s3, config_env=self.config_env, config_file=self.config_file, @@ -186,6 +193,7 @@ def test_all_args_guided_no_to_authorization_confirmation_prompt( @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.get_template_parameters") @patch("samcli.commands.deploy.guided_context.get_template_data") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") @patch.object(GuidedConfig, "get_config_ctx", MagicMock(return_value=(None, get_mock_sam_config()))) @patch("samcli.commands.deploy.guided_context.prompt") @patch("samcli.commands.deploy.guided_context.confirm") @@ -193,6 +201,7 @@ def test_all_args_guided( self, mock_confirm, mock_prompt, + mock_signer_config_per_function, mock_get_template_data, mock_get_template_parameters, mockauth_per_resource, @@ -224,6 +233,8 @@ def test_all_args_guided( mock_managed_stack.return_value = "managed-s3-bucket" + mock_signer_config_per_function.return_value = ({}, {}) + with patch.object(GuidedConfig, "save_config", MagicMock(return_value=True)) as mock_save_config: do_cli( template_file=self.template_file, @@ -246,6 +257,7 @@ def test_all_args_guided( metadata=self.metadata, guided=True, confirm_changeset=True, + signing_profiles=self.signing_profiles, resolve_s3=self.resolve_s3, config_env=self.config_env, config_file=self.config_file, @@ -269,6 +281,7 @@ def test_all_args_guided( region="us-east-1", profile=self.profile, confirm_changeset=True, + signing_profiles=self.signing_profiles, ) context_mock.run.assert_called_with() @@ -286,6 +299,7 @@ def test_all_args_guided( s3_bucket="managed-s3-bucket", stack_name="sam-app", s3_prefix="sam-app", + signing_profiles=self.signing_profiles, ) mock_managed_stack.assert_called_with(profile=self.profile, region="us-east-1") self.assertEqual(context_mock.run.call_count, 1) @@ -298,6 +312,7 @@ def test_all_args_guided( @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.get_template_data") @patch("samcli.commands.deploy.guided_context.get_template_parameters") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") @patch.object( GuidedConfig, "get_config_ctx", @@ -309,6 +324,7 @@ def test_all_args_guided_no_save_echo_param_to_config( self, mock_confirm, mock_prompt, + mock_signer_config_per_function, mock_get_template_parameters, mock_get_template_data, mockauth_per_resource, @@ -340,6 +356,7 @@ def test_all_args_guided_no_save_echo_param_to_config( mock_confirm.side_effect = [True, False, True, True] mock_managed_stack.return_value = "managed-s3-bucket" + mock_signer_config_per_function.return_value = ({}, {}) do_cli( template_file=self.template_file, @@ -362,6 +379,7 @@ def test_all_args_guided_no_save_echo_param_to_config( metadata=self.metadata, guided=True, confirm_changeset=True, + signing_profiles=self.signing_profiles, resolve_s3=self.resolve_s3, config_env=self.config_env, config_file=self.config_file, @@ -389,6 +407,7 @@ def test_all_args_guided_no_save_echo_param_to_config( region="us-east-1", profile=self.profile, confirm_changeset=True, + signing_profiles=self.signing_profiles, ) context_mock.run.assert_called_with() @@ -423,6 +442,7 @@ def test_all_args_guided_no_save_echo_param_to_config( @patch("samcli.commands.deploy.guided_context.get_template_data") @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.get_template_parameters") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") @patch.object( GuidedConfig, "get_config_ctx", @@ -438,6 +458,7 @@ def test_all_args_guided_no_params_save_config( mock_sam_config, mock_confirm, mock_prompt, + mock_signer_config_per_function, mock_get_template_parameters, mock_managed_stack, mock_get_template_data, @@ -447,7 +468,6 @@ def test_all_args_guided_no_params_save_config( mock_package_context, mock_package_click, ): - context_mock = Mock() mockauth_per_resource.return_value = [("HelloWorldResource", False)] @@ -457,6 +477,7 @@ def test_all_args_guided_no_params_save_config( mock_confirm.side_effect = [True, False, True, True] mock_get_cmd_names.return_value = ["deploy"] mock_managed_stack.return_value = "managed-s3-bucket" + mock_signer_config_per_function.return_value = ({}, {}) do_cli( template_file=self.template_file, @@ -482,6 +503,7 @@ def test_all_args_guided_no_params_save_config( resolve_s3=self.resolve_s3, config_env=self.config_env, config_file=self.config_file, + signing_profiles=self.signing_profiles, ) mock_deploy_context.assert_called_with( @@ -502,6 +524,7 @@ def test_all_args_guided_no_params_save_config( region="us-east-1", profile=self.profile, confirm_changeset=True, + signing_profiles=self.signing_profiles, ) context_mock.run.assert_called_with() @@ -530,6 +553,7 @@ def test_all_args_guided_no_params_save_config( @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.get_template_data") @patch("samcli.commands.deploy.guided_context.get_template_parameters") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") @patch.object(GuidedConfig, "get_config_ctx", MagicMock(return_value=(None, get_mock_sam_config()))) @patch("samcli.commands.deploy.guided_context.prompt") @patch("samcli.commands.deploy.guided_context.confirm") @@ -537,6 +561,7 @@ def test_all_args_guided_no_params_no_save_config( self, mock_confirm, mock_prompt, + mock_signer_config_per_function, mock_get_template_parameters, mock_get_template_data, mockauth_per_resource, @@ -555,6 +580,7 @@ def test_all_args_guided_no_params_no_save_config( mock_confirm.side_effect = [True, False, True, False] mock_managed_stack.return_value = "managed-s3-bucket" + mock_signer_config_per_function.return_value = ({}, {}) with patch.object(GuidedConfig, "save_config", MagicMock(return_value=False)) as mock_save_config: @@ -582,6 +608,7 @@ def test_all_args_guided_no_params_no_save_config( resolve_s3=self.resolve_s3, config_file=self.config_file, config_env=self.config_env, + signing_profiles=self.signing_profiles, ) mock_deploy_context.assert_called_with( @@ -602,6 +629,7 @@ def test_all_args_guided_no_params_no_save_config( region="us-east-1", profile=self.profile, confirm_changeset=True, + signing_profiles=self.signing_profiles, ) context_mock.run.assert_called_with() @@ -645,6 +673,7 @@ def test_all_args_resolve_s3( resolve_s3=True, config_file=self.config_file, config_env=self.config_env, + signing_profiles=self.signing_profiles, ) mock_deploy_context.assert_called_with( @@ -665,6 +694,7 @@ def test_all_args_resolve_s3( region=self.region, profile=self.profile, confirm_changeset=self.confirm_changeset, + signing_profiles=self.signing_profiles, ) context_mock.run.assert_called_with() @@ -696,4 +726,5 @@ def test_resolve_s3_and_s3_bucket_both_set(self): resolve_s3=True, config_file=self.config_file, config_env=self.config_env, + signing_profiles=self.signing_profiles, ) diff --git a/tests/unit/commands/deploy/test_deploy_context.py b/tests/unit/commands/deploy/test_deploy_context.py index 1e85d2530f..7e0445cafe 100644 --- a/tests/unit/commands/deploy/test_deploy_context.py +++ b/tests/unit/commands/deploy/test_deploy_context.py @@ -28,6 +28,7 @@ def setUp(self): region=None, profile=None, confirm_changeset=False, + signing_profiles=None, ) def test_template_improper(self): diff --git a/tests/unit/commands/deploy/test_guided_config.py b/tests/unit/commands/deploy/test_guided_config.py index ad7490fe4c..1bb063dc0b 100644 --- a/tests/unit/commands/deploy/test_guided_config.py +++ b/tests/unit/commands/deploy/test_guided_config.py @@ -51,4 +51,8 @@ def test_read_config_showcase(self): def test_save_config(self, patched_cmd_names): patched_cmd_names.return_value = ["local", "start-api"] # Should save with no errors. - self.gc.save_config(parameter_overrides={"a": "b"}, port="9090") + signing_profiles = { + "a": {"profile_name": "profile", "profile_owner": "owner"}, + "b": {"profile_name": "profile"}, + } + self.gc.save_config(parameter_overrides={"a": "b"}, signing_profiles=signing_profiles, port="9090") diff --git a/tests/unit/commands/deploy/test_guided_context.py b/tests/unit/commands/deploy/test_guided_context.py index 0be659dfec..5f2f7ec279 100644 --- a/tests/unit/commands/deploy/test_guided_context.py +++ b/tests/unit/commands/deploy/test_guided_context.py @@ -23,8 +23,15 @@ def setUp(self): @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.get_template_data") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") def test_guided_prompts_check_defaults_non_public_resources( - self, patched_get_template_data, patchedauth_per_resource, patched_manage_stack, patched_confirm, patched_prompt + self, + patched_signer_config_per_function, + patched_get_template_data, + patchedauth_per_resource, + patched_manage_stack, + patched_confirm, + patched_prompt, ): # Series of inputs to confirmations so that full range of questions are asked. patchedauth_per_resource.return_value = [ @@ -32,6 +39,7 @@ def test_guided_prompts_check_defaults_non_public_resources( ] patched_confirm.side_effect = [True, False, "", True] patched_manage_stack.return_value = "managed_s3_stack" + patched_signer_config_per_function.return_value = ({}, {}) self.gc.guided_prompts(parameter_override_keys=None) # Now to check for all the defaults on confirmations. expected_confirmation_calls = [ @@ -54,13 +62,21 @@ def test_guided_prompts_check_defaults_non_public_resources( @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.get_template_data") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") def test_guided_prompts_check_defaults_public_resources( - self, patched_get_template_data, patchedauth_per_resource, patched_manage_stack, patched_confirm, patched_prompt + self, + patched_signer_config_per_function, + patched_get_template_data, + patchedauth_per_resource, + patched_manage_stack, + patched_confirm, + patched_prompt, ): # Series of inputs to confirmations so that full range of questions are asked. patchedauth_per_resource.return_value = [("HelloWorldFunction", False)] patched_confirm.side_effect = [True, False, True, False, ""] patched_manage_stack.return_value = "managed_s3_stack" + patched_signer_config_per_function.return_value = ({}, {}) self.gc.guided_prompts(parameter_override_keys=None) # Now to check for all the defaults on confirmations. expected_confirmation_calls = [ @@ -101,15 +117,18 @@ def test_guided_prompts_check_defaults_public_resources( @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.get_template_data") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") def test_guided_prompts_with_given_capabilities( self, given_capabilities, + patched_signer_config_per_function, patched_get_template_data, patchedauth_per_resource, patched_manage_stack, patched_confirm, patched_prompt, ): + patched_signer_config_per_function.return_value = ({}, {}) self.gc.capabilities = given_capabilities # Series of inputs to confirmations so that full range of questions are asked. patched_confirm.side_effect = [True, False, "", True] @@ -136,13 +155,21 @@ def test_guided_prompts_with_given_capabilities( @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.get_template_data") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") def test_guided_prompts_check_configuration_file_prompt_calls( - self, patched_get_template_data, patchedauth_per_resource, patched_manage_stack, patched_confirm, patched_prompt + self, + patched_signer_config_per_function, + patched_get_template_data, + patchedauth_per_resource, + patched_manage_stack, + patched_confirm, + patched_prompt, ): # Series of inputs to confirmations so that full range of questions are asked. patchedauth_per_resource.return_value = [("HelloWorldFunction", False)] patched_confirm.side_effect = [True, False, True, True, ""] patched_manage_stack.return_value = "managed_s3_stack" + patched_signer_config_per_function.return_value = ({}, {}) self.gc.guided_prompts(parameter_override_keys=None) # Now to check for all the defaults on confirmations. expected_confirmation_calls = [ @@ -178,13 +205,21 @@ def test_guided_prompts_check_configuration_file_prompt_calls( @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.get_template_data") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") def test_guided_prompts_check_parameter_from_template( - self, patched_get_template_data, patchedauth_per_resource, patched_manage_stack, patched_confirm, patched_prompt + self, + patched_signer_config_per_function, + patched_get_template_data, + patchedauth_per_resource, + patched_manage_stack, + patched_confirm, + patched_prompt, ): # Series of inputs to confirmations so that full range of questions are asked. patchedauth_per_resource.return_value = [("HelloWorldFunction", False)] patched_confirm.side_effect = [True, False, True, False, ""] patched_manage_stack.return_value = "managed_s3_stack" + patched_signer_config_per_function.return_value = ({}, {}) parameter_override_from_template = {"MyTestKey": {"Default": "MyTemplateDefaultVal"}} self.gc.parameter_overrides_from_cmdline = {} self.gc.guided_prompts(parameter_override_keys=parameter_override_from_template) @@ -217,13 +252,21 @@ def test_guided_prompts_check_parameter_from_template( @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.get_template_data") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") def test_guided_prompts_check_parameter_from_cmd_or_config( - self, patched_get_template_data, patchedauth_per_resource, patched_manage_stack, patched_confirm, patched_prompt + self, + patched_signer_config_per_function, + patched_get_template_data, + patchedauth_per_resource, + patched_manage_stack, + patched_confirm, + patched_prompt, ): # Series of inputs to confirmations so that full range of questions are asked. patchedauth_per_resource.return_value = [("HelloWorldFunction", False)] patched_confirm.side_effect = [True, False, True, False, ""] patched_manage_stack.return_value = "managed_s3_stack" + patched_signer_config_per_function.return_value = ({}, {}) parameter_override_from_template = {"MyTestKey": {"Default": "MyTemplateDefaultVal"}} self.gc.parameter_overrides_from_cmdline = {"MyTestKey": "OverridedValFromCmdLine", "NotUsedKey": "NotUsedVal"} self.gc.guided_prompts(parameter_override_keys=parameter_override_from_template) @@ -250,3 +293,72 @@ def test_guided_prompts_check_parameter_from_cmd_or_config( call(f"\t{self.gc.start_bold}Capabilities{self.gc.end_bold}", default=["CAPABILITY_IAM"], type=ANY), ] self.assertEqual(expected_prompt_calls, patched_prompt.call_args_list) + + @parameterized.expand( + [ + (False, ({"MyFunction1"}, {})), + (True, ({"MyFunction1"}, {})), + (True, ({"MyFunction1", "MyFunction2"}, {})), + (True, ({"MyFunction1"}, {"MyLayer1": {"MyFunction1"}})), + (True, ({"MyFunction1"}, {"MyLayer1": {"MyFunction1"}, "MyLayer2": {"MyFunction1"}})), + ] + ) + @patch("samcli.commands.deploy.guided_context.prompt") + @patch("samcli.commands.deploy.guided_context.confirm") + @patch("samcli.commands.deploy.code_signer_utils.prompt") + @patch("samcli.commands.deploy.guided_context.manage_stack") + @patch("samcli.commands.deploy.guided_context.auth_per_resource") + @patch("samcli.commands.deploy.guided_context.get_template_data") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") + def test_guided_prompts_with_code_signing( + self, + given_sign_packages_flag, + given_code_signing_configs, + patched_signer_config_per_function, + patched_get_template_data, + patchedauth_per_resource, + patched_manage_stack, + patched_code_signer_prompt, + patched_confirm, + patched_prompt, + ): + patched_signer_config_per_function.return_value = given_code_signing_configs + # Series of inputs to confirmations so that full range of questions are asked. + patched_confirm.side_effect = [True, False, given_sign_packages_flag, "", True] + self.gc.guided_prompts(parameter_override_keys=None) + # Now to check for all the defaults on confirmations. + expected_confirmation_calls = [ + call(f"\t{self.gc.start_bold}Confirm changes before deploy{self.gc.end_bold}", default=True), + call(f"\t{self.gc.start_bold}Allow SAM CLI IAM role creation{self.gc.end_bold}", default=True), + call( + f"\t{self.gc.start_bold}Do you want to sign your code?{self.gc.end_bold}", + default=True, + ), + call(f"\t{self.gc.start_bold}Save arguments to configuration file{self.gc.end_bold}", default=True), + ] + self.assertEqual(expected_confirmation_calls, patched_confirm.call_args_list) + + # Now to check for all the defaults on prompts. + expected_prompt_calls = [ + call(f"\t{self.gc.start_bold}Stack Name{self.gc.end_bold}", default="test", type=click.STRING), + call(f"\t{self.gc.start_bold}AWS Region{self.gc.end_bold}", default="region", type=click.STRING), + call(f"\t{self.gc.start_bold}Capabilities{self.gc.end_bold}", default=["CAPABILITY_IAM"], type=ANY), + ] + self.assertEqual(expected_prompt_calls, patched_prompt.call_args_list) + + if given_sign_packages_flag: + # we are going to expect prompts for functions and layers for each one of them, + # so multiply the number of prompt calls + number_of_functions = len(given_code_signing_configs[0]) + number_of_layers = len(given_code_signing_configs[1]) + expected_code_sign_calls = [ + call(f"\t{self.gc.start_bold}Signing Profile Name{self.gc.end_bold}", default=None, type=click.STRING), + call( + f"\t{self.gc.start_bold}Signing Profile Owner Account ID (optional){self.gc.end_bold}", + default="", + type=click.STRING, + show_default=False, + ), + ] + expected_code_sign_calls = expected_code_sign_calls * (number_of_functions + number_of_layers) + self.assertEqual(expected_code_sign_calls, patched_code_signer_prompt.call_args_list) diff --git a/tests/unit/commands/local/lib/test_local_lambda.py b/tests/unit/commands/local/lib/test_local_lambda.py index 19848ae10d..dcf2a66f39 100644 --- a/tests/unit/commands/local/lib/test_local_lambda.py +++ b/tests/unit/commands/local/lib/test_local_lambda.py @@ -217,6 +217,7 @@ def test_must_work_with_override_values( layers=[], events=None, metadata=None, + codesign_config_arn=None, ) self.local_lambda.env_vars_values = env_vars_values @@ -260,6 +261,7 @@ def test_must_not_work_with_invalid_override_values(self, env_vars_values, expec layers=[], events=None, metadata=None, + codesign_config_arn=None, ) self.local_lambda.env_vars_values = env_vars_values @@ -293,6 +295,7 @@ def test_must_work_with_invalid_environment_variable(self, environment_variable, layers=[], events=None, metadata=None, + codesign_config_arn=None, ) self.local_lambda.env_vars_values = {} @@ -357,6 +360,7 @@ def test_must_work(self, FunctionConfigMock, is_debugging_mock, resolve_code_pat layers=layers, events=None, metadata=None, + codesign_config_arn=None, ) config = "someconfig" @@ -404,6 +408,7 @@ def test_timeout_set_to_max_during_debugging(self, FunctionConfigMock, is_debugg layers=[], events=None, metadata=None, + codesign_config_arn=None, ) config = "someconfig" diff --git a/tests/unit/commands/local/lib/test_sam_function_provider.py b/tests/unit/commands/local/lib/test_sam_function_provider.py index 87324d6394..a8933a8dc2 100644 --- a/tests/unit/commands/local/lib/test_sam_function_provider.py +++ b/tests/unit/commands/local/lib/test_sam_function_provider.py @@ -72,6 +72,16 @@ class TestSamFunctionProviderEndToEnd(TestCase): "Handler": "index.handler", }, }, + "LambdaFuncWithCodeSignConfig": { + "Type": "AWS::Lambda::Function", + "Properties": { + "FunctionName": "LambdaFuncWithCodeSignConfig", + "Code": "./some/path/to/code", + "Runtime": "nodejs4.3", + "Handler": "index.handler", + "CodeSigningConfigArn": "codeSignConfigArn", + }, + }, "OtherResource": { "Type": "AWS::Serverless::Api", "Properties": {"StageName": "prod", "DefinitionUri": "s3://bucket/key"}, @@ -100,6 +110,7 @@ def setUp(self): layers=[], events=None, metadata=None, + codesign_config_arn=None, ), ), ( @@ -117,6 +128,7 @@ def setUp(self): layers=[], events=None, metadata=None, + codesign_config_arn=None, ), ), ( @@ -134,6 +146,7 @@ def setUp(self): layers=[], events=None, metadata=None, + codesign_config_arn=None, ), ), ( @@ -151,6 +164,7 @@ def setUp(self): layers=[], events=None, metadata=None, + codesign_config_arn=None, ), ), ( @@ -168,6 +182,7 @@ def setUp(self): layers=[], events=None, metadata=None, + codesign_config_arn=None, ), ), ( @@ -185,6 +200,7 @@ def setUp(self): layers=[], events=None, metadata=None, + codesign_config_arn=None, ), ), ( @@ -202,6 +218,7 @@ def setUp(self): layers=[], events=None, metadata=None, + codesign_config_arn=None, ), ), ( @@ -219,6 +236,25 @@ def setUp(self): layers=[], events=None, metadata=None, + codesign_config_arn=None, + ), + ), + ( + "LambdaFuncWithCodeSignConfig", + Function( + name="LambdaFuncWithCodeSignConfig", + functionname="LambdaFuncWithCodeSignConfig", + runtime="nodejs4.3", + handler="index.handler", + codeuri="./some/path/to/code", + memory=None, + timeout=None, + environment=None, + rolearn=None, + layers=[], + events=None, + metadata=None, + codesign_config_arn="codeSignConfigArn", ), ), ] @@ -239,6 +275,7 @@ def test_get_all_must_return_all_functions(self): "LambdaFunc1", "LambdaFuncWithLocalPath", "LambdaFuncWithFunctionNameOverride", + "LambdaFuncWithCodeSignConfig", } self.assertEqual(result, expected) @@ -359,6 +396,7 @@ def test_must_convert(self): layers=["Layer1", "Layer2"], events=None, metadata=None, + codesign_config_arn=None, ) result = SamFunctionProvider._convert_sam_function_resource(name, properties, ["Layer1", "Layer2"]) @@ -383,6 +421,7 @@ def test_must_skip_non_existent_properties(self): layers=[], events=None, metadata=None, + codesign_config_arn=None, ) result = SamFunctionProvider._convert_sam_function_resource(name, properties, []) @@ -447,6 +486,7 @@ def test_must_convert(self): layers=["Layer1", "Layer2"], events=None, metadata=None, + codesign_config_arn=None, ) result = SamFunctionProvider._convert_lambda_function_resource(name, properties, ["Layer1", "Layer2"]) @@ -471,6 +511,7 @@ def test_must_skip_non_existent_properties(self): layers=[], events=None, metadata=None, + codesign_config_arn=None, ) result = SamFunctionProvider._convert_lambda_function_resource(name, properties, []) @@ -570,6 +611,7 @@ def test_must_return_function_value(self): layers=[], events=None, metadata=None, + codesign_config_arn=None, ) provider.functions = {"func1": function} diff --git a/tests/unit/commands/package/test_command.py b/tests/unit/commands/package/test_command.py index 7aecd1fa8d..548b3fdc53 100644 --- a/tests/unit/commands/package/test_command.py +++ b/tests/unit/commands/package/test_command.py @@ -20,6 +20,7 @@ def setUp(self): self.region = None self.profile = None self.resolve_s3 = False + self.signing_profiles = {"MyFunction": {"profile_name": "ProfileName", "profile_owner": "Profile Owner"}} @patch("samcli.commands.package.command.click") @patch("samcli.commands.package.package_context.PackageContext") @@ -41,6 +42,7 @@ def test_all_args(self, package_command_context, click_mock): region=self.region, profile=self.profile, resolve_s3=self.resolve_s3, + signing_profiles=self.signing_profiles, ) package_command_context.assert_called_with( @@ -55,6 +57,7 @@ def test_all_args(self, package_command_context, click_mock): metadata=self.metadata, region=self.region, profile=self.profile, + signing_profiles=self.signing_profiles, ) context_mock.run.assert_called_with() @@ -81,6 +84,7 @@ def test_all_args_resolve_s3(self, mock_managed_stack, package_command_context, region=self.region, profile=self.profile, resolve_s3=True, + signing_profiles=self.signing_profiles, ) package_command_context.assert_called_with( @@ -95,6 +99,7 @@ def test_all_args_resolve_s3(self, mock_managed_stack, package_command_context, metadata=self.metadata, region=self.region, profile=self.profile, + signing_profiles=self.signing_profiles, ) context_mock.run.assert_called_with() @@ -115,6 +120,7 @@ def test_resolve_s3_and_s3_bucket_both_set(self): region=self.region, profile=self.profile, resolve_s3=True, + signing_profiles=self.signing_profiles, ) def test_resolve_s3_and_s3_bucket_both_not_set(self): @@ -132,4 +138,5 @@ def test_resolve_s3_and_s3_bucket_both_not_set(self): region=self.region, profile=self.profile, resolve_s3=False, + signing_profiles=self.signing_profiles, ) diff --git a/tests/unit/commands/samconfig/test_samconfig.py b/tests/unit/commands/samconfig/test_samconfig.py index 050944bcd3..0eb1f90bc2 100644 --- a/tests/unit/commands/samconfig/test_samconfig.py +++ b/tests/unit/commands/samconfig/test_samconfig.py @@ -316,6 +316,7 @@ def test_package(self, do_cli_mock): "metadata": '{"m1": "value1", "m2": "value2"}', "region": "myregion", "output_template_file": "output.yaml", + "signing_profiles": "function=profile:owner", } with samconfig_parameters(["package"], self.scratch_dir, **config_values) as config_path: @@ -342,6 +343,7 @@ def test_package(self, do_cli_mock): True, False, {"m1": "value1", "m2": "value2"}, + {"function": {"profile_name": "profile", "profile_owner": "owner"}}, "myregion", None, False, @@ -369,6 +371,7 @@ def test_deploy(self, do_cli_mock): "guided": True, "confirm_changeset": True, "region": "myregion", + "signing_profiles": "function=profile:owner", } with samconfig_parameters(["deploy"], self.scratch_dir, **config_values) as config_path: @@ -406,6 +409,7 @@ def test_deploy(self, do_cli_mock): True, "myregion", None, + {"function": {"profile_name": "profile", "profile_owner": "owner"}}, False, "samconfig.toml", "default", @@ -433,6 +437,7 @@ def test_deploy_different_parameter_override_format(self, do_cli_mock): "guided": True, "confirm_changeset": True, "region": "myregion", + "signing_profiles": "function=profile:owner", } with samconfig_parameters(["deploy"], self.scratch_dir, **config_values) as config_path: @@ -470,6 +475,7 @@ def test_deploy_different_parameter_override_format(self, do_cli_mock): True, "myregion", None, + {"function": {"profile_name": "profile", "profile_owner": "owner"}}, False, "samconfig.toml", "default", diff --git a/tests/unit/lib/build_module/test_build_graph.py b/tests/unit/lib/build_module/test_build_graph.py index 74938b27a3..48c340ae1c 100644 --- a/tests/unit/lib/build_module/test_build_graph.py +++ b/tests/unit/lib/build_module/test_build_graph.py @@ -40,10 +40,23 @@ def generate_function( rolearn="rolearn", layers="layers", events="events", + codesign_config_arn="codesign_config_arn", metadata={}, ): return Function( - name, function_name, runtime, memory, timeout, handler, codeuri, environment, rolearn, layers, events, metadata + name, + function_name, + runtime, + memory, + timeout, + handler, + codeuri, + environment, + rolearn, + layers, + events, + metadata, + codesign_config_arn, ) diff --git a/tests/unit/lib/intrinsic_resolver/test_intrinsics_symbol_table.py b/tests/unit/lib/intrinsic_resolver/test_intrinsics_symbol_table.py index 3e822cb5ab..278241e10d 100644 --- a/tests/unit/lib/intrinsic_resolver/test_intrinsics_symbol_table.py +++ b/tests/unit/lib/intrinsic_resolver/test_intrinsics_symbol_table.py @@ -119,7 +119,7 @@ def test_arn_resolver_default_service_name(self): def test_arn_resolver_lambda(self): res = IntrinsicsSymbolTable().arn_resolver("test", service_name="lambda") - self.assertEquals(res, "arn:aws:lambda:us-east-1:123456789012:function:test") + self.assertEqual(res, "arn:aws:lambda:us-east-1:123456789012:function:test") def test_arn_resolver_sns(self): res = IntrinsicsSymbolTable().arn_resolver("test", service_name="sns") @@ -128,7 +128,7 @@ def test_arn_resolver_sns(self): def test_arn_resolver_lambda_with_function_name(self): template = {"Resources": {"LambdaFunction": {"Properties": {"FunctionName": "function-name-override"}}}} res = IntrinsicsSymbolTable(template=template).arn_resolver("LambdaFunction", service_name="lambda") - self.assertEquals(res, "arn:aws:lambda:us-east-1:123456789012:function:function-name-override") + self.assertEqual(res, "arn:aws:lambda:us-east-1:123456789012:function:function-name-override") def test_resolver_ignore_errors(self): resolver = IntrinsicsSymbolTable() diff --git a/tests/unit/lib/package/test_artifact_exporter.py b/tests/unit/lib/package/test_artifact_exporter.py index 5384f36e0c..70aea956d2 100644 --- a/tests/unit/lib/package/test_artifact_exporter.py +++ b/tests/unit/lib/package/test_artifact_exporter.py @@ -49,6 +49,8 @@ class TestArtifactExporter(unittest.TestCase): def setUp(self): self.s3_uploader_mock = Mock() self.s3_uploader_mock.s3.meta.endpoint_url = "https://s3.some-valid-region.amazonaws.com" + self.code_signer_mock = Mock() + self.code_signer_mock.should_sign_package.return_value = False def test_all_resources_export(self): uploaded_s3_url = "s3://foo/bar?versionId=baz" @@ -87,19 +89,24 @@ def test_all_resources_export(self): def test_invalid_export_resource(self): with patch("samcli.lib.package.artifact_exporter.upload_local_artifacts") as upload_local_artifacts_mock: s3_uploader_mock = Mock() + code_signer_mock = Mock() upload_local_artifacts_mock.reset_mock() - resource_obj = ServerlessFunctionResource(uploader=s3_uploader_mock) + resource_obj = ServerlessFunctionResource(uploader=s3_uploader_mock, code_signer=code_signer_mock) resource_id = "id" resource_dict = {"InlineCode": "code"} parent_dir = "dir" resource_obj.export(resource_id, resource_dict, parent_dir) upload_local_artifacts_mock.assert_not_called() + code_signer_mock.should_sign_package.assert_not_called() + code_signer_mock.sign_package.assert_not_called() def _helper_verify_export_resources( self, test_class, uploaded_s3_url, upload_local_artifacts_mock, expected_result ): s3_uploader_mock = Mock() + code_signer_mock = Mock() + code_signer_mock.should_sign_package.return_value = False upload_local_artifacts_mock.reset_mock() resource_id = "id" @@ -117,13 +124,24 @@ def _helper_verify_export_resources( upload_local_artifacts_mock.return_value = uploaded_s3_url - resource_obj = test_class(uploader=s3_uploader_mock) + resource_obj = test_class(uploader=s3_uploader_mock, code_signer=code_signer_mock) resource_obj.export(resource_id, resource_dict, parent_dir) - upload_local_artifacts_mock.assert_called_once_with( - resource_id, resource_dict, test_class.PROPERTY_NAME, parent_dir, s3_uploader_mock - ) + if test_class in ( + ApiGatewayRestApiResource, + LambdaFunctionResource, + ElasticBeanstalkApplicationVersion, + LambdaLayerVersionResource, + ): + upload_local_artifacts_mock.assert_called_once_with( + resource_id, resource_dict, test_class.PROPERTY_NAME, parent_dir, s3_uploader_mock + ) + else: + upload_local_artifacts_mock.assert_called_once_with( + resource_id, resource_dict, test_class.PROPERTY_NAME, parent_dir, s3_uploader_mock, None + ) + code_signer_mock.sign_package.assert_not_called() if "." in test_class.PROPERTY_NAME: top_level_property_name = test_class.PROPERTY_NAME.split(".")[0] result = resource_dict[top_level_property_name] @@ -280,7 +298,7 @@ def test_upload_local_artifacts_local_folder(self, zip_and_upload_mock): absolute_artifact_path = make_abs_path(parent_dir, artifact_path) - zip_and_upload_mock.assert_called_once_with(absolute_artifact_path, mock.ANY) + zip_and_upload_mock.assert_called_once_with(absolute_artifact_path, mock.ANY, None) @patch("samcli.lib.package.artifact_exporter.zip_and_upload") def test_upload_local_artifacts_no_path(self, zip_and_upload_mock): @@ -297,7 +315,7 @@ def test_upload_local_artifacts_no_path(self, zip_and_upload_mock): result = upload_local_artifacts(resource_id, resource_dict, property_name, parent_dir, self.s3_uploader_mock) self.assertEqual(result, expected_s3_url) - zip_and_upload_mock.assert_called_once_with(parent_dir, mock.ANY) + zip_and_upload_mock.assert_called_once_with(parent_dir, mock.ANY, None) self.s3_uploader_mock.upload_with_dedup.assert_not_called() @patch("samcli.lib.package.artifact_exporter.zip_and_upload") @@ -353,7 +371,7 @@ def test_resource(self, upload_local_artifacts_mock): class MockResource(Resource): PROPERTY_NAME = "foo" - resource = MockResource(self.s3_uploader_mock) + resource = MockResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" resource_dict = {} @@ -366,7 +384,7 @@ class MockResource(Resource): resource.export(resource_id, resource_dict, parent_dir) upload_local_artifacts_mock.assert_called_once_with( - resource_id, resource_dict, resource.PROPERTY_NAME, parent_dir, self.s3_uploader_mock + resource_id, resource_dict, resource.PROPERTY_NAME, parent_dir, self.s3_uploader_mock, None ) self.assertEqual(resource_dict[resource.PROPERTY_NAME], s3_url) @@ -385,7 +403,7 @@ class MockResource(Resource): PROPERTY_NAME = "foo" FORCE_ZIP = True - resource = MockResource(self.s3_uploader_mock) + resource = MockResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" resource_dict = {} @@ -406,9 +424,11 @@ class MockResource(Resource): resource.export(resource_id, resource_dict, parent_dir) - zip_and_upload_mock.assert_called_once_with(tmp_dir, mock.ANY) + zip_and_upload_mock.assert_called_once_with(tmp_dir, mock.ANY, None) rmtree_mock.assert_called_once_with(tmp_dir) is_zipfile_mock.assert_called_once_with(original_path) + self.code_signer_mock.should_sign_package.assert_called_once_with(resource_id) + self.code_signer_mock.sign_package.assert_not_called() self.assertEqual(resource_dict[resource.PROPERTY_NAME], s3_url) @patch("shutil.rmtree") @@ -426,7 +446,7 @@ class MockResource(Resource): PROPERTY_NAME = "foo" FORCE_ZIP = True - resource = MockResource(self.s3_uploader_mock) + resource = MockResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" resource_dict = {} @@ -447,6 +467,8 @@ class MockResource(Resource): zip_and_upload_mock.assert_not_called() rmtree_mock.assert_not_called() is_zipfile_mock.assert_called_once_with(original_path) + self.code_signer_mock.should_sign_package.assert_called_once_with(resource_id) + self.code_signer_mock.sign_package.assert_not_called() self.assertEqual(resource_dict[resource.PROPERTY_NAME], s3_url) @patch("shutil.rmtree") @@ -460,7 +482,7 @@ def test_resource_without_force_zip( class MockResourceNoForceZip(Resource): PROPERTY_NAME = "foo" - resource = MockResourceNoForceZip(self.s3_uploader_mock) + resource = MockResourceNoForceZip(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" resource_dict = {} @@ -481,6 +503,8 @@ class MockResourceNoForceZip(Resource): zip_and_upload_mock.assert_not_called() rmtree_mock.assert_not_called() is_zipfile_mock.assert_called_once_with(original_path) + self.code_signer_mock.should_sign_package.assert_called_once_with(resource_id) + self.code_signer_mock.sign_package.assert_not_called() self.assertEqual(resource_dict[resource.PROPERTY_NAME], s3_url) @patch("samcli.lib.package.artifact_exporter.upload_local_artifacts") @@ -490,7 +514,7 @@ def test_resource_empty_property_value(self, upload_local_artifacts_mock): class MockResource(Resource): PROPERTY_NAME = "foo" - resource = MockResource(self.s3_uploader_mock) + resource = MockResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" resource_dict = {} @@ -502,8 +526,10 @@ class MockResource(Resource): resource_dict = {} resource.export(resource_id, resource_dict, parent_dir) upload_local_artifacts_mock.assert_called_once_with( - resource_id, resource_dict, resource.PROPERTY_NAME, parent_dir, self.s3_uploader_mock + resource_id, resource_dict, resource.PROPERTY_NAME, parent_dir, self.s3_uploader_mock, None ) + self.code_signer_mock.should_sign_package.assert_called_once_with(resource_id) + self.code_signer_mock.sign_package.assert_not_called() self.assertEqual(resource_dict[resource.PROPERTY_NAME], s3_url) @patch("samcli.lib.package.artifact_exporter.upload_local_artifacts") @@ -513,7 +539,7 @@ def test_resource_property_value_dict(self, upload_local_artifacts_mock): class MockResource(Resource): PROPERTY_NAME = "foo" - resource = MockResource(self.s3_uploader_mock) + resource = MockResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" resource_dict = {} resource_dict[resource.PROPERTY_NAME] = "/path/to/file" @@ -535,7 +561,7 @@ class MockResource(Resource): PROPERTY_NAME = "foo" PACKAGE_NULL_PROPERTY = False - resource = MockResource(self.s3_uploader_mock) + resource = MockResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" resource_dict = {} parent_dir = "dir" @@ -553,7 +579,7 @@ def test_resource_export_fails(self, upload_local_artifacts_mock): class MockResource(Resource): PROPERTY_NAME = "foo" - resource = MockResource(self.s3_uploader_mock) + resource = MockResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" resource_dict = {} resource_dict[resource.PROPERTY_NAME] = "/path/to/file" @@ -581,7 +607,7 @@ class MockResource(ResourceWithS3UrlDict): OBJECT_KEY_PROPERTY = "o" VERSION_PROPERTY = "v" - resource = MockResource(self.s3_uploader_mock) + resource = MockResource(self.s3_uploader_mock, self.code_signer_mock) # Case 1: Property value is a path to file resource_id = "id" @@ -602,9 +628,27 @@ class MockResource(ResourceWithS3UrlDict): resource_dict[resource.PROPERTY_NAME], {"b": "bucket", "o": "key1/key2", "v": "SomeVersionNumber"} ) + @patch("samcli.lib.package.artifact_exporter.upload_local_artifacts") + def test_resource_with_signing_configuration(self, upload_local_artifacts_mock): + class MockResource(Resource): + PROPERTY_NAME = "foo" + + code_signer_mock = Mock() + code_signer_mock.should_sign_package.return_value = True + code_signer_mock.sign_package.return_value = "signed_s3_location" + upload_local_artifacts_mock.return_value = "non_signed_s3_location" + + resource = MockResource(self.s3_uploader_mock, code_signer_mock) + + resource_id = "id" + resource_dict = {resource.PROPERTY_NAME: "/path/to/file"} + parent_dir = "dir" + resource.export(resource_id, resource_dict, parent_dir) + self.assertEqual(resource_dict[resource.PROPERTY_NAME], "signed_s3_location") + @patch("samcli.lib.package.artifact_exporter.Template") def test_export_cloudformation_stack(self, TemplateMock): - stack_resource = CloudFormationStackResource(self.s3_uploader_mock) + stack_resource = CloudFormationStackResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" property_name = stack_resource.PROPERTY_NAME @@ -628,13 +672,15 @@ def test_export_cloudformation_stack(self, TemplateMock): self.assertEqual(resource_dict[property_name], result_path_style_s3_url) - TemplateMock.assert_called_once_with(template_path, parent_dir, self.s3_uploader_mock) + TemplateMock.assert_called_once_with( + template_path, parent_dir, self.s3_uploader_mock, self.code_signer_mock + ) template_instance_mock.export.assert_called_once_with() self.s3_uploader_mock.upload_with_dedup.assert_called_once_with(mock.ANY, "template") self.s3_uploader_mock.to_path_style_s3_url.assert_called_once_with("world", None) def test_export_cloudformation_stack_no_upload_path_is_s3url(self): - stack_resource = CloudFormationStackResource(self.s3_uploader_mock) + stack_resource = CloudFormationStackResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" property_name = stack_resource.PROPERTY_NAME s3_url = "s3://hello/world" @@ -646,7 +692,7 @@ def test_export_cloudformation_stack_no_upload_path_is_s3url(self): self.s3_uploader_mock.upload_with_dedup.assert_not_called() def test_export_cloudformation_stack_no_upload_path_is_httpsurl(self): - stack_resource = CloudFormationStackResource(self.s3_uploader_mock) + stack_resource = CloudFormationStackResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" property_name = stack_resource.PROPERTY_NAME s3_url = "https://s3.amazonaws.com/hello/world" @@ -658,7 +704,7 @@ def test_export_cloudformation_stack_no_upload_path_is_httpsurl(self): self.s3_uploader_mock.upload_with_dedup.assert_not_called() def test_export_cloudformation_stack_no_upload_path_is_s3_region_httpsurl(self): - stack_resource = CloudFormationStackResource(self.s3_uploader_mock) + stack_resource = CloudFormationStackResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" property_name = stack_resource.PROPERTY_NAME @@ -670,7 +716,7 @@ def test_export_cloudformation_stack_no_upload_path_is_s3_region_httpsurl(self): self.s3_uploader_mock.upload_with_dedup.assert_not_called() def test_export_cloudformation_stack_no_upload_path_is_empty(self): - stack_resource = CloudFormationStackResource(self.s3_uploader_mock) + stack_resource = CloudFormationStackResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" property_name = stack_resource.PROPERTY_NAME s3_url = "s3://hello/world" @@ -683,7 +729,7 @@ def test_export_cloudformation_stack_no_upload_path_is_empty(self): self.s3_uploader_mock.upload_with_dedup.assert_not_called() def test_export_cloudformation_stack_no_upload_path_not_file(self): - stack_resource = CloudFormationStackResource(self.s3_uploader_mock) + stack_resource = CloudFormationStackResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" property_name = stack_resource.PROPERTY_NAME s3_url = "s3://hello/world" @@ -697,7 +743,7 @@ def test_export_cloudformation_stack_no_upload_path_not_file(self): @patch("samcli.lib.package.artifact_exporter.Template") def test_export_serverless_application(self, TemplateMock): - stack_resource = ServerlessApplicationResource(self.s3_uploader_mock) + stack_resource = ServerlessApplicationResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" property_name = stack_resource.PROPERTY_NAME @@ -721,13 +767,15 @@ def test_export_serverless_application(self, TemplateMock): self.assertEqual(resource_dict[property_name], result_path_style_s3_url) - TemplateMock.assert_called_once_with(template_path, parent_dir, self.s3_uploader_mock) + TemplateMock.assert_called_once_with( + template_path, parent_dir, self.s3_uploader_mock, self.code_signer_mock + ) template_instance_mock.export.assert_called_once_with() self.s3_uploader_mock.upload_with_dedup.assert_called_once_with(mock.ANY, "template") self.s3_uploader_mock.to_path_style_s3_url.assert_called_once_with("world", None) def test_export_serverless_application_no_upload_path_is_s3url(self): - stack_resource = ServerlessApplicationResource(self.s3_uploader_mock) + stack_resource = ServerlessApplicationResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" property_name = stack_resource.PROPERTY_NAME s3_url = "s3://hello/world" @@ -739,7 +787,7 @@ def test_export_serverless_application_no_upload_path_is_s3url(self): self.s3_uploader_mock.upload_with_dedup.assert_not_called() def test_export_serverless_application_no_upload_path_is_httpsurl(self): - stack_resource = ServerlessApplicationResource(self.s3_uploader_mock) + stack_resource = ServerlessApplicationResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" property_name = stack_resource.PROPERTY_NAME s3_url = "https://s3.amazonaws.com/hello/world" @@ -751,7 +799,7 @@ def test_export_serverless_application_no_upload_path_is_httpsurl(self): self.s3_uploader_mock.upload_with_dedup.assert_not_called() def test_export_serverless_application_no_upload_path_is_empty(self): - stack_resource = ServerlessApplicationResource(self.s3_uploader_mock) + stack_resource = ServerlessApplicationResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" property_name = stack_resource.PROPERTY_NAME @@ -762,7 +810,7 @@ def test_export_serverless_application_no_upload_path_is_empty(self): self.s3_uploader_mock.upload_with_dedup.assert_not_called() def test_export_serverless_application_no_upload_path_not_file(self): - stack_resource = ServerlessApplicationResource(self.s3_uploader_mock) + stack_resource = ServerlessApplicationResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" property_name = stack_resource.PROPERTY_NAME @@ -774,7 +822,7 @@ def test_export_serverless_application_no_upload_path_not_file(self): self.s3_uploader_mock.upload_with_dedup.assert_not_called() def test_export_serverless_application_no_upload_path_is_dictionary(self): - stack_resource = ServerlessApplicationResource(self.s3_uploader_mock) + stack_resource = ServerlessApplicationResource(self.s3_uploader_mock, self.code_signer_mock) resource_id = "id" property_name = stack_resource.PROPERTY_NAME @@ -814,7 +862,11 @@ def test_template_export_metadata(self, yaml_parse_mock): with patch("samcli.lib.package.artifact_exporter.open", open_mock(read_data=template_str)) as open_mock: template_exporter = Template( - template_path, parent_dir, self.s3_uploader_mock, metadata_to_export=metadata_to_export + template_path, + parent_dir, + self.s3_uploader_mock, + self.code_signer_mock, + metadata_to_export=metadata_to_export, ) exported_template = template_exporter.export() self.assertEqual(exported_template, template_dict) @@ -861,7 +913,9 @@ def test_template_export(self, yaml_parse_mock): # Patch the file open method to return template string with patch("samcli.lib.package.artifact_exporter.open", open_mock(read_data=template_str)) as open_mock: - template_exporter = Template(template_path, parent_dir, self.s3_uploader_mock, resources_to_export) + template_exporter = Template( + template_path, parent_dir, self.s3_uploader_mock, self.code_signer_mock, resources_to_export + ) exported_template = template_exporter.export() self.assertEqual(exported_template, template_dict) @@ -869,9 +923,9 @@ def test_template_export(self, yaml_parse_mock): self.assertEqual(1, yaml_parse_mock.call_count) - resource_type1_class.assert_called_once_with(self.s3_uploader_mock) + resource_type1_class.assert_called_once_with(self.s3_uploader_mock, self.code_signer_mock) resource_type1_instance.export.assert_called_once_with("Resource1", mock.ANY, template_dir) - resource_type2_class.assert_called_once_with(self.s3_uploader_mock) + resource_type2_class.assert_called_once_with(self.s3_uploader_mock, self.code_signer_mock) resource_type2_instance.export.assert_called_once_with("Resource2", mock.ANY, template_dir) @patch("samcli.lib.package.artifact_exporter.yaml_parse") @@ -909,7 +963,9 @@ def test_template_export_with_globals(self, yaml_parse_mock): # Patch the file open method to return template string with patch("samcli.lib.package.artifact_exporter.open", open_mock(read_data=template_str)) as open_mock: - template_exporter = Template(template_path, parent_dir, self.s3_uploader_mock, resources_to_export) + template_exporter = Template( + template_path, parent_dir, self.s3_uploader_mock, self.code_signer_mock, resources_to_export + ) exported_template = template_exporter.export() self.assertEqual(exported_template, template_dict) self.assertEqual( @@ -1076,12 +1132,12 @@ def test_template_export_path_be_folder(self): template_path = "/path/foo" # Set parent_dir to be a non-existent folder with self.assertRaises(ValueError): - Template(template_path, "somefolder", self.s3_uploader_mock) + Template(template_path, "somefolder", self.s3_uploader_mock, self.code_signer_mock) # Set parent_dir to be a real folder, but just a relative path with self.make_temp_dir() as dirname: with self.assertRaises(ValueError): - Template(template_path, os.path.relpath(dirname), self.s3_uploader_mock) + Template(template_path, os.path.relpath(dirname), self.s3_uploader_mock, self.code_signer_mock) def test_make_zip(self): test_file_creator = FileCreator() diff --git a/tests/unit/lib/package/test_code_signer.py b/tests/unit/lib/package/test_code_signer.py new file mode 100644 index 0000000000..c1d1f1c9ae --- /dev/null +++ b/tests/unit/lib/package/test_code_signer.py @@ -0,0 +1,174 @@ +from unittest import TestCase +from unittest.mock import MagicMock +from parameterized import parameterized, param + +from samcli.lib.package.code_signer import CodeSigner, CodeSigningJobFailureException, CodeSigningInitiationException + + +class TestCodeSigner(TestCase): + def setUp(self): + self.signer_client = MagicMock() + + @parameterized.expand( + [ + param({}), + param({"MyFunction": {"profile_name": "profile1", "profile_owner": ""}}), + ] + ) + def test_should_sign_package(self, signing_profiles): + code_signer = CodeSigner(self.signer_client, signing_profiles) + + should_sign_package = code_signer.should_sign_package("MyFunction") + if len(signing_profiles) > 0: + self.assertEqual(should_sign_package, True) + else: + self.assertEqual(should_sign_package, False) + + @parameterized.expand(["", "MyProfileOwner"]) + def test_sign_package_successfully(self, given_profile_owner): + # prepare code signing config + resource_id = "MyFunction" + given_profile_name = "MyProfile" + signing_profiles = {resource_id: {"profile_name": given_profile_name, "profile_owner": given_profile_owner}} + code_signer = CodeSigner(self.signer_client, signing_profiles) + + # prepare object details that is going to be signed + given_s3_bucket = "bucket" + given_s3_key = "path/to/unsigned/package" + given_s3_url = f"s3://{given_s3_bucket}/{given_s3_key}" + given_s3_object_version = "objectVersion" + given_signed_object_location = "path/to/signed/object" + given_job_id = "signingJobId" + + # prepare method mocks + mocked_waiter = MagicMock() + self.signer_client.start_signing_job.return_value = {"jobId": given_job_id} + self.signer_client.get_waiter.return_value = mocked_waiter + self.signer_client.describe_signing_job.return_value = { + "status": "Succeeded", + "signedObject": {"s3": {"key": given_signed_object_location}}, + } + + # make the actual call + signed_object_location = code_signer.sign_package(resource_id, given_s3_url, given_s3_object_version) + + # start verifying calls and responses + expected_start_signing_job_params_source = { + "s3": {"bucketName": given_s3_bucket, "key": given_s3_key, "version": given_s3_object_version} + } + expected_start_signing_job_params_destination = { + "s3": {"bucketName": given_s3_bucket, "prefix": "path/to/unsigned/signed_"} + } + + if given_profile_owner: + self.signer_client.start_signing_job.assert_called_with( + source=expected_start_signing_job_params_source, + destination=expected_start_signing_job_params_destination, + profileName=given_profile_name, + profileOwner=given_profile_owner, + ) + else: + self.signer_client.start_signing_job.assert_called_with( + source=expected_start_signing_job_params_source, + destination=expected_start_signing_job_params_destination, + profileName=given_profile_name, + ) + + self.signer_client.get_waiter.assert_called_with("successful_signing_job") + mocked_waiter.wait.assert_called_with(jobId=given_job_id, WaiterConfig={"Delay": 5}) + + self.signer_client.describe_signing_job.assert_called_with(jobId=given_job_id) + self.assertEqual(signed_object_location, f"s3://{given_s3_bucket}/{given_signed_object_location}") + + def test_sign_package_should_fail_if_status_not_succeed(self): + # prepare code signing config + resource_id = "MyFunction" + given_profile_name = "MyProfile" + signing_profiles = {resource_id: {"profile_name": given_profile_name, "profile_owner": ""}} + code_signer = CodeSigner(self.signer_client, signing_profiles) + + # prepare object details that is going to be signed + given_s3_bucket = "bucket" + given_s3_key = "path/to/unsigned/package" + given_s3_url = f"s3://{given_s3_bucket}/{given_s3_key}" + given_s3_object_version = "objectVersion" + given_signed_object_location = "path/to/signed/object" + given_job_id = "signingJobId" + + # prepare method mocks + mocked_waiter = MagicMock() + self.signer_client.start_signing_job.return_value = {"jobId": given_job_id} + self.signer_client.get_waiter.return_value = mocked_waiter + self.signer_client.describe_signing_job.return_value = { + "status": "Fail", + "signedObject": {"s3": {"key": given_signed_object_location}}, + } + + # make the actual call + with self.assertRaises(CodeSigningJobFailureException): + code_signer.sign_package(resource_id, given_s3_url, given_s3_object_version) + + def test_sign_package_should_fail_if_initiate_signing_fails(self): + # prepare code signing config + resource_id = "MyFunction" + given_profile_name = "MyProfile" + signing_profiles = {resource_id: {"profile_name": given_profile_name, "profile_owner": ""}} + code_signer = CodeSigner(self.signer_client, signing_profiles) + + # prepare object details that is going to be signed + given_s3_bucket = "bucket" + given_s3_key = "path/to/unsigned/package" + given_s3_url = f"s3://{given_s3_bucket}/{given_s3_key}" + given_s3_object_version = "objectVersion" + + # mock exception when initiating signing job + self.signer_client.start_signing_job.side_effect = Exception() + + with self.assertRaises(CodeSigningInitiationException): + code_signer.sign_package(resource_id, given_s3_url, given_s3_object_version) + + def test_sign_package_should_fail_if_waiter_fails(self): + # prepare code signing config + resource_id = "MyFunction" + given_profile_name = "MyProfile" + signing_profiles = {resource_id: {"profile_name": given_profile_name, "profile_owner": ""}} + code_signer = CodeSigner(self.signer_client, signing_profiles) + + # prepare object details that is going to be signed + given_s3_bucket = "bucket" + given_s3_key = "path/to/unsigned/package" + given_s3_url = f"s3://{given_s3_bucket}/{given_s3_key}" + given_s3_object_version = "objectVersion" + given_job_id = "signingJobId" + + # prepare method mocks + self.signer_client.start_signing_job.return_value = {"jobId": given_job_id} + self.signer_client.get_waiter.side_effect = Exception() + + # make the actual call + with self.assertRaises(CodeSigningJobFailureException): + code_signer.sign_package(resource_id, given_s3_url, given_s3_object_version) + + def test_sign_package_should_fail_if_describe_job_fails(self): + # prepare code signing config + resource_id = "MyFunction" + given_profile_name = "MyProfile" + signing_profiles = {resource_id: {"profile_name": given_profile_name, "profile_owner": ""}} + code_signer = CodeSigner(self.signer_client, signing_profiles) + + # prepare object details that is going to be signed + given_s3_bucket = "bucket" + given_s3_key = "path/to/unsigned/package" + given_s3_url = f"s3://{given_s3_bucket}/{given_s3_key}" + given_s3_object_version = "objectVersion" + given_job_id = "signingJobId" + + # prepare method mocks + mocked_waiter = MagicMock() + self.signer_client.start_signing_job.return_value = {"jobId": given_job_id} + self.signer_client.get_waiter.return_value = mocked_waiter + self.signer_client.describe_signing_job.side_effect = Exception() + + # make the actual call + with self.assertRaises(CodeSigningJobFailureException): + code_signer.sign_package(resource_id, given_s3_url, given_s3_object_version) diff --git a/tests/unit/lib/package/test_s3_uploader.py b/tests/unit/lib/package/test_s3_uploader.py index a3d13906e8..c40c4c6cf4 100644 --- a/tests/unit/lib/package/test_s3_uploader.py +++ b/tests/unit/lib/package/test_s3_uploader.py @@ -187,3 +187,24 @@ def test_s3_upload_with_dedup(self): self.assertEqual( s3_url, "s3://{0}/{1}/{2}.zip".format(self.bucket_name, self.prefix, file_checksum(f.name)) ) + + def test_get_version_of_artifact(self): + s3_uploader = S3Uploader( + s3_client=self.s3, + bucket_name=self.bucket_name, + prefix=self.prefix, + kms_key_id=self.kms_key_id, + force_upload=self.force_upload, + ) + + given_version_id = "versionId" + given_s3_bucket = "mybucket" + given_s3_location = "my/object/location" + given_s3_url = f"s3://{given_s3_bucket}/{given_s3_location}" + + self.s3.get_object_tagging.return_value = {"VersionId": given_version_id} + + version_id = s3_uploader.get_version_of_artifact(given_s3_url) + + self.s3.get_object_tagging.assert_called_with(Bucket=given_s3_bucket, Key=given_s3_location) + self.assertEqual(version_id, given_version_id) diff --git a/tests/unit/local/docker/test_lambda_container.py b/tests/unit/local/docker/test_lambda_container.py index 83dba359b4..a7b81a2190 100644 --- a/tests/unit/local/docker/test_lambda_container.py +++ b/tests/unit/local/docker/test_lambda_container.py @@ -197,8 +197,7 @@ def test_must_provide_entrypoint_for_certain_runtimes_only(self, runtime): elif runtime in RUNTIMES_WITH_DEBUG_ENV_VARS_ONLY: result, _ = LambdaContainer._get_debug_settings(runtime, self.debug_options) - self.assertEquals("/var/rapid/init", result, "{} runtime must not override entrypoint".format(runtime)) - + self.assertEqual("/var/rapid/init", result, "{} runtime must not override entrypoint".format(runtime)) else: with self.assertRaises(DebuggingNotSupported): LambdaContainer._get_debug_settings(runtime, self.debug_options)