Skip to content

Commit f8e4b94

Browse files
authored
Merge pull request #79 from schubergphilis/feat-allow-jira-transition
feat: Adding support to pass optional Jira intermediate transition before closing the finding
2 parents 01e812d + 59b9c1a commit f8e4b94

9 files changed

+46
-17
lines changed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ Deploys two Lambda functions:
4141
* Deploys an additional Jira lambda function and a Step function for orchestration, triggered by an EventBridge rule.
4242
* Non-suppressed findings with severity above a threshold result in ticket creation and workflow status update from `NEW` to `NOTIFIED`.
4343
* **ProductName Filtering**: You can optionally filter which AWS product findings create Jira tickets using `jira_integration.include_product_names` (default = `[]`, meaning all products). For example, set to `["Security Hub"]` to create tickets only for Security Hub findings, or `["Inspector"]` for Inspector findings only. Common values: `"Security Hub"`, `"Inspector"`, `"GuardDuty"`, `"Macie"`. The filtering is implemented at the Step Function level for optimal performance.
44-
* Auto-closing can be activated with `jira_integration.autoclose_enabled` (default = false). Using the issue number in the finding note, the function transitions issues using `jira_integration.autoclose_transition_name` and `jira_integration.autoclose_comment`. Criteria for being forwarded for automatic ticket closure are:
44+
* Auto-closing can be activated with `jira_integration.autoclose_enabled` (default = false). Using the issue number in the finding note, the function transitions issues using `jira_integration.autoclose_transition_name` and `jira_integration.autoclose_comment`.
45+
* **Intermediate Transition**: Optionally specify `jira_integration.include_intermediate_transition` to transition the ticket through an intermediate status before closing it. This is useful for Jira workflows that require tickets to pass through specific statuses (e.g., "Review", "In Progress") before reaching the final closed state. If not specified, tickets are closed directly using `autoclose_transition_name`.
46+
* Criteria for being forwarded for automatic ticket closure are:
4547
* Workflow Status "RESOLVED"
4648
* Workflow Status "NOTIFIED" and one of:
4749
* Record State "ARCHIVED"
@@ -145,7 +147,7 @@ A lambda layer provides aws-lambda-powertools. To have these dependencies locall
145147
| <a name="input_findings_manager_trigger_lambda"></a> [findings\_manager\_trigger\_lambda](#input\_findings\_manager\_trigger\_lambda) | Findings Manager Lambda settings - Manage Security Hub findings in response to S3 file upload triggers | <pre>object({<br/> name = optional(string, "securityhub-findings-manager-trigger")<br/> log_level = optional(string, "ERROR")<br/> memory_size = optional(number, 256)<br/> timeout = optional(number, 300)<br/><br/> security_group_egress_rules = optional(list(object({<br/> cidr_ipv4 = optional(string)<br/> cidr_ipv6 = optional(string)<br/> description = string<br/> from_port = optional(number, 0)<br/> ip_protocol = optional(string, "-1")<br/> prefix_list_id = optional(string)<br/> referenced_security_group_id = optional(string)<br/> to_port = optional(number, 0)<br/> })), [])<br/> })</pre> | `{}` | no |
146148
| <a name="input_findings_manager_worker_lambda"></a> [findings\_manager\_worker\_lambda](#input\_findings\_manager\_worker\_lambda) | Findings Manager Lambda settings - Manage Security Hub findings in response to SQS trigger | <pre>object({<br/> name = optional(string, "securityhub-findings-manager-worker")<br/> log_level = optional(string, "ERROR")<br/> memory_size = optional(number, 256)<br/> timeout = optional(number, 900)<br/><br/> security_group_egress_rules = optional(list(object({<br/> cidr_ipv4 = optional(string)<br/> cidr_ipv6 = optional(string)<br/> description = string<br/> from_port = optional(number, 0)<br/> ip_protocol = optional(string, "-1")<br/> prefix_list_id = optional(string)<br/> referenced_security_group_id = optional(string)<br/> to_port = optional(number, 0)<br/> })), [])<br/> })</pre> | `{}` | no |
147149
| <a name="input_jira_eventbridge_iam_role_name"></a> [jira\_eventbridge\_iam\_role\_name](#input\_jira\_eventbridge\_iam\_role\_name) | The name of the role which will be assumed by EventBridge rules for Jira integration | `string` | `"SecurityHubFindingsManagerJiraEventBridge"` | no |
148-
| <a name="input_jira_integration"></a> [jira\_integration](#input\_jira\_integration) | Findings Manager - Jira integration settings | <pre>object({<br/> enabled = optional(bool, false)<br/> autoclose_enabled = optional(bool, false)<br/> autoclose_comment = optional(string, "Security Hub finding has been resolved. Autoclosing the issue.")<br/> autoclose_transition_name = optional(string, "Close Issue")<br/> credentials_secretsmanager_arn = optional(string)<br/> credentials_ssm_secret_arn = optional(string)<br/> exclude_account_ids = optional(list(string), [])<br/> finding_severity_normalized_threshold = optional(number, 70)<br/> include_account_ids = optional(list(string), [])<br/> include_product_names = optional(list(string), [])<br/> issue_custom_fields = optional(map(string), {})<br/> issue_type = optional(string, "Security Advisory")<br/> project_key = string<br/><br/> security_group_egress_rules = optional(list(object({<br/> cidr_ipv4 = optional(string)<br/> cidr_ipv6 = optional(string)<br/> description = string<br/> from_port = optional(number, 0)<br/> ip_protocol = optional(string, "-1")<br/> prefix_list_id = optional(string)<br/> referenced_security_group_id = optional(string)<br/> to_port = optional(number, 0)<br/> })), [])<br/><br/> lambda_settings = optional(object({<br/> name = optional(string, "securityhub-findings-manager-jira")<br/> log_level = optional(string, "INFO")<br/> memory_size = optional(number, 256)<br/> timeout = optional(number, 60)<br/> }), {<br/> name = "securityhub-findings-manager-jira"<br/> iam_role_name = "SecurityHubFindingsManagerJiraLambda"<br/> log_level = "INFO"<br/> memory_size = 256<br/> timeout = 60<br/> security_group_egress_rules = []<br/> })<br/><br/> step_function_settings = optional(object({<br/> log_level = optional(string, "ERROR")<br/> retention = optional(number, 90)<br/> }), {<br/> log_level = "ERROR"<br/> retention = 90<br/> })<br/><br/> })</pre> | <pre>{<br/> "enabled": false,<br/> "project_key": null<br/>}</pre> | no |
150+
| <a name="input_jira_integration"></a> [jira\_integration](#input\_jira\_integration) | Findings Manager - Jira integration settings | <pre>object({<br/> enabled = optional(bool, false)<br/> autoclose_enabled = optional(bool, false)<br/> autoclose_comment = optional(string, "Security Hub finding has been resolved. Autoclosing the issue.")<br/> autoclose_transition_name = optional(string, "Close Issue")<br/> credentials_secretsmanager_arn = optional(string)<br/> credentials_ssm_secret_arn = optional(string)<br/> exclude_account_ids = optional(list(string), [])<br/> finding_severity_normalized_threshold = optional(number, 70)<br/> include_account_ids = optional(list(string), [])<br/> include_intermediate_transition = optional(string)<br/> include_product_names = optional(list(string), [])<br/> issue_custom_fields = optional(map(string), {})<br/> issue_type = optional(string, "Security Advisory")<br/> project_key = string<br/><br/> security_group_egress_rules = optional(list(object({<br/> cidr_ipv4 = optional(string)<br/> cidr_ipv6 = optional(string)<br/> description = string<br/> from_port = optional(number, 0)<br/> ip_protocol = optional(string, "-1")<br/> prefix_list_id = optional(string)<br/> referenced_security_group_id = optional(string)<br/> to_port = optional(number, 0)<br/> })), [])<br/><br/> lambda_settings = optional(object({<br/> name = optional(string, "securityhub-findings-manager-jira")<br/> log_level = optional(string, "INFO")<br/> memory_size = optional(number, 256)<br/> timeout = optional(number, 60)<br/> }), {<br/> name = "securityhub-findings-manager-jira"<br/> iam_role_name = "SecurityHubFindingsManagerJiraLambda"<br/> log_level = "INFO"<br/> memory_size = 256<br/> timeout = 60<br/> security_group_egress_rules = []<br/> })<br/><br/> step_function_settings = optional(object({<br/> log_level = optional(string, "ERROR")<br/> retention = optional(number, 90)<br/> }), {<br/> log_level = "ERROR"<br/> retention = 90<br/> })<br/><br/> })</pre> | <pre>{<br/> "enabled": false,<br/> "project_key": null<br/>}</pre> | no |
149151
| <a name="input_jira_step_function_iam_role_name"></a> [jira\_step\_function\_iam\_role\_name](#input\_jira\_step\_function\_iam\_role\_name) | The name of the role which will be assumed by AWS Step Function for Jira integration | `string` | `"SecurityHubFindingsManagerJiraStepFunction"` | no |
150152
| <a name="input_lambda_runtime"></a> [lambda\_runtime](#input\_lambda\_runtime) | The version of Python to use for the Lambda functions | `string` | `"python3.12"` | no |
151153
| <a name="input_rules_filepath"></a> [rules\_filepath](#input\_rules\_filepath) | Pathname to the file that stores the manager rules | `string` | `""` | no |

files/lambda-artifacts/findings-manager-jira/findings_manager_jira.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def lambda_handler(event: dict, context: LambdaContext):
4646
'JIRA_AUTOCLOSE_COMMENT', DEFAULT_JIRA_AUTOCLOSE_COMMENT)
4747
jira_autoclose_transition = os.getenv(
4848
'JIRA_AUTOCLOSE_TRANSITION', DEFAULT_JIRA_AUTOCLOSE_TRANSITION)
49+
jira_intermediate_transition = os.getenv('JIRA_INTERMEDIATE_TRANSITION', '')
4950
jira_issue_custom_fields = os.environ['JIRA_ISSUE_CUSTOM_FIELDS']
5051
jira_issue_type = os.environ['JIRA_ISSUE_TYPE']
5152
jira_project_key = os.environ['JIRA_PROJECT_KEY']
@@ -147,7 +148,7 @@ def lambda_handler(event: dict, context: LambdaContext):
147148
f"Failed to retrieve Jira issue {jira_issue_id}: {e}. Cannot autoclose.")
148149
return # Skip further processing for this finding
149150
helpers.close_jira_issue(
150-
jira_client, issue, jira_autoclose_transition, jira_autoclose_comment)
151+
jira_client, issue, jira_autoclose_transition, jira_autoclose_comment, jira_intermediate_transition)
151152
if workflow_status == STATUS_NOTIFIED:
152153
# Resolve SecHub finding as it will be reopened anyway in case the compliance fails
153154
# Also change the note to prevent a second run with RESOLVED status.

files/lambda-artifacts/findings-manager-jira/helpers.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,26 +190,50 @@ def create_jira_issue(jira_client: JIRA, project_key: str, issue_type: str, even
190190
raise e
191191

192192

193-
def close_jira_issue(jira_client: JIRA, issue: Issue, transition_name: str, comment: str) -> None:
193+
def close_jira_issue(jira_client: JIRA, issue: Issue, transition_name: str, comment: str, intermediate_transition: str = '') -> None:
194194
"""
195-
Close a Jira issue.
195+
Close a Jira issue, intelligently handling transitions based on available options.
196+
197+
If the direct close transition is not available, but an intermediate transition is specified
198+
and available, the function will perform the intermediate transition first, then close.
196199
197200
Args:
198201
jira_client (JIRA): An authenticated Jira client instance.
199202
issue (Issue): The Jira issue to close.
203+
transition_name (str): The name of the final transition to close the issue.
204+
comment (str): The comment to add when closing the issue.
205+
intermediate_transition (str): Optional intermediate transition to perform before closing.
200206
201207
Raises:
202208
Exception: If there is an error closing the Jira issue.
203209
"""
204210

205211
try:
212+
# Get available transitions for the current issue state
213+
available_transitions = jira_client.transitions(issue)
214+
available_transition_names = {t['name']: t['id'] for t in available_transitions}
215+
216+
logger.info(f"Available transitions for issue {issue.key}: {list(available_transition_names.keys())}")
217+
218+
# Check if intermediate transition is available
219+
if intermediate_transition and intermediate_transition in available_transition_names:
220+
# Direct close not available, but intermediate transition is
221+
logger.info(f"Direct close not available. Performing intermediate transition '{intermediate_transition}' for issue {issue.key}")
222+
jira_client.transition_issue(issue, available_transition_names[intermediate_transition])
223+
logger.info(f"Transitioned Jira issue {issue.key} to intermediate status: {intermediate_transition}")
224+
225+
# Refresh the issue to get the updated status
226+
issue = jira_client.issue(issue.key)
227+
228+
# Attempt to close the issue
206229
transition_id = jira_client.find_transitionid_by_name(issue, transition_name)
207230
if transition_id is None:
208231
logger.warning(f"Failed to close Jira issue: Invalid transition.")
209232
return
210233
jira_client.add_comment(issue, comment)
211234
jira_client.transition_issue(issue, transition_id, comment=comment)
212235
logger.info(f"Closed Jira issue: {issue.key}")
236+
213237
except Exception as e:
214238
logger.error(f"Failed to close Jira issue {issue.key}: {e}")
215239
raise e
-154 KB
Binary file not shown.
29.3 KB
Binary file not shown.
-153 KB
Binary file not shown.
29.7 KB
Binary file not shown.

jira_lambda.tf

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -105,17 +105,18 @@ module "jira_lambda" {
105105
timeout = var.jira_integration.lambda_settings.timeout
106106

107107
environment = {
108-
EXCLUDE_ACCOUNT_FILTER = jsonencode(var.jira_integration.exclude_account_ids)
109-
INCLUDE_ACCOUNT_FILTER = jsonencode(var.jira_integration.include_account_ids)
110-
JIRA_AUTOCLOSE_COMMENT = var.jira_integration.autoclose_comment
111-
JIRA_AUTOCLOSE_TRANSITION = var.jira_integration.autoclose_transition_name
112-
JIRA_ISSUE_CUSTOM_FIELDS = jsonencode(var.jira_integration.issue_custom_fields)
113-
JIRA_ISSUE_TYPE = var.jira_integration.issue_type
114-
JIRA_PROJECT_KEY = var.jira_integration.project_key
115-
JIRA_SECRET_ARN = var.jira_integration.credentials_secretsmanager_arn != null ? var.jira_integration.credentials_secretsmanager_arn : var.jira_integration.credentials_ssm_secret_arn
116-
JIRA_SECRET_TYPE = var.jira_integration.credentials_secretsmanager_arn != null ? "SECRETSMANAGER" : "SSM"
117-
LOG_LEVEL = var.jira_integration.lambda_settings.log_level
118-
POWERTOOLS_LOGGER_LOG_EVENT = "false"
119-
POWERTOOLS_SERVICE_NAME = "securityhub-findings-manager-jira"
108+
EXCLUDE_ACCOUNT_FILTER = jsonencode(var.jira_integration.exclude_account_ids)
109+
INCLUDE_ACCOUNT_FILTER = jsonencode(var.jira_integration.include_account_ids)
110+
JIRA_AUTOCLOSE_COMMENT = var.jira_integration.autoclose_comment
111+
JIRA_AUTOCLOSE_TRANSITION = var.jira_integration.autoclose_transition_name
112+
JIRA_INTERMEDIATE_TRANSITION = var.jira_integration.include_intermediate_transition != null ? var.jira_integration.include_intermediate_transition : ""
113+
JIRA_ISSUE_CUSTOM_FIELDS = jsonencode(var.jira_integration.issue_custom_fields)
114+
JIRA_ISSUE_TYPE = var.jira_integration.issue_type
115+
JIRA_PROJECT_KEY = var.jira_integration.project_key
116+
JIRA_SECRET_ARN = var.jira_integration.credentials_secretsmanager_arn != null ? var.jira_integration.credentials_secretsmanager_arn : var.jira_integration.credentials_ssm_secret_arn
117+
JIRA_SECRET_TYPE = var.jira_integration.credentials_secretsmanager_arn != null ? "SECRETSMANAGER" : "SSM"
118+
LOG_LEVEL = var.jira_integration.lambda_settings.log_level
119+
POWERTOOLS_LOGGER_LOG_EVENT = "false"
120+
POWERTOOLS_SERVICE_NAME = "securityhub-findings-manager-jira"
120121
}
121122
}

variables.tf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ variable "jira_integration" {
9696
exclude_account_ids = optional(list(string), [])
9797
finding_severity_normalized_threshold = optional(number, 70)
9898
include_account_ids = optional(list(string), [])
99+
include_intermediate_transition = optional(string)
99100
include_product_names = optional(list(string), [])
100101
issue_custom_fields = optional(map(string), {})
101102
issue_type = optional(string, "Security Advisory")

0 commit comments

Comments
 (0)