Skip to content

Commit 4c04a32

Browse files
CM-25773 - Support TF Plan scans (#153)
* CM-25773- initial commit * lint fix * remove new line * add try-except * change list to List * key error exception handler parser test * prettier * encoding * fix path * lint * review fixing * renaming + typo fixing * remove redundant json load check * change soft fail * add json.loads wrapper * lint * remove constructor * refactor iac parsing * handle null after + add test * Fix error handling * Fix replacement of files * fix iac doc manipulation * revert iac manipultaion outside pre scan doc * adding tests for different plans + handle delete action list + update readme * fix readme * fix readme * fixing * pr fixing * pr fixing * lint * test fix * add test for generate document * lint * update test * typo fix * move \n --------- Co-authored-by: Ilya Siamionau <ilya.siamionau@cycode.com>
1 parent 78e8763 commit 4c04a32

File tree

21 files changed

+5355
-12
lines changed

21 files changed

+5355
-12
lines changed

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ This guide will guide you through both installation and usage.
2525
1. [License Compliance Option](#license-compliance-option)
2626
2. [Severity Threshold](#severity-threshold)
2727
5. [Path Scan](#path-scan)
28+
1. [Terraform Plan Scan](#terraform-plan-scan)
2829
6. [Commit History Scan](#commit-history-scan)
2930
1. [Commit Range Option](#commit-range-option)
3031
7. [Pre-Commit Scan](#pre-commit-scan)
@@ -421,6 +422,36 @@ For example, consider a scenario in which you want to scan the directory located
421422
422423
`cycode scan path ~/home/git/codebase`
423424
425+
426+
### Terraform Plan Scan
427+
428+
Cycode CLI supports Terraform plan scanning (supporting Terraform 0.12 and later)
429+
430+
Terraform plan file must be in JSON format (having `.json` extension)
431+
432+
433+
_How to generate a Terraform plan from Terraform configuration file?_
434+
435+
1. Initialize a working directory that contains Terraform configuration file:
436+
437+
`terraform init`
438+
439+
440+
2. Create Terraform execution plan and save the binary output:
441+
442+
`terraform plan -out={tfplan_output}`
443+
444+
445+
3. Convert the binary output file into readable JSON:
446+
447+
`terraform show -json {tfplan_output} > {tfplan}.json`
448+
449+
450+
4. Scan your `{tfplan}.json` with Cycode CLI:
451+
452+
`cycode scan -t iac path ~/PATH/TO/YOUR/{tfplan}.json`
453+
454+
424455
## Commit History Scan
425456
426457
A commit history scan is limited to a local repository’s previous commits, focused on finding any secrets within the commit history, instead of examining the repository’s current state.

cycode/cli/code_scanner.py

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,20 @@
1616
from cycode.cli.ci_integrations import get_commit_range
1717
from cycode.cli.config import configuration_manager
1818
from cycode.cli.exceptions import custom_exceptions
19-
from cycode.cli.helpers import sca_code_scanner
19+
from cycode.cli.helpers import sca_code_scanner, tf_content_generator
2020
from cycode.cli.models import CliError, CliErrors, Document, DocumentDetections, LocalScanResult, Severity
2121
from cycode.cli.printers import ConsolePrinter
2222
from cycode.cli.user_settings.config_file_manager import ConfigFileManager
2323
from cycode.cli.utils import scan_utils
2424
from cycode.cli.utils.path_utils import (
25+
change_filename_extension,
2526
get_file_content,
2627
get_file_size,
2728
get_path_by_os,
2829
get_relevant_files_in_path,
2930
is_binary_file,
3031
is_sub_path,
32+
load_json,
3133
)
3234
from cycode.cli.utils.progress_bar import ProgressBarSection
3335
from cycode.cli.utils.progress_bar import logger as progress_bar_logger
@@ -328,18 +330,22 @@ def scan_disk_files(context: click.Context, path: str, files_to_scan: List[str])
328330

329331
is_git_diff = False
330332

331-
documents: List[Document] = []
332-
for file in files_to_scan:
333-
progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES)
333+
try:
334+
documents: List[Document] = []
335+
for file in files_to_scan:
336+
progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES)
334337

335-
content = get_file_content(file)
336-
if not content:
337-
continue
338+
content = get_file_content(file)
339+
if not content:
340+
continue
341+
342+
documents.append(_generate_document(file, scan_type, content, is_git_diff))
338343

339-
documents.append(Document(file, content, is_git_diff))
344+
perform_pre_scan_documents_actions(context, scan_type, documents, is_git_diff)
345+
scan_documents(context, documents, is_git_diff=is_git_diff, scan_parameters=scan_parameters)
340346

341-
perform_pre_scan_documents_actions(context, scan_type, documents, is_git_diff)
342-
scan_documents(context, documents, is_git_diff=is_git_diff, scan_parameters=scan_parameters)
347+
except Exception as e:
348+
_handle_exception(context, e)
343349

344350

345351
def set_issue_detected_by_scan_results(context: click.Context, scan_results: List[LocalScanResult]) -> None:
@@ -574,7 +580,7 @@ def perform_pre_scan_documents_actions(
574580
context: click.Context, scan_type: str, documents_to_scan: List[Document], is_git_diff: bool = False
575581
) -> None:
576582
if scan_type == consts.SCA_SCAN_TYPE:
577-
logger.debug('Perform pre scan document actions')
583+
logger.debug('Perform pre scan document add_dependencies_tree_document action')
578584
sca_code_scanner.add_dependencies_tree_document(context, documents_to_scan, is_git_diff)
579585

580586

@@ -1099,6 +1105,37 @@ def _is_file_extension_supported(scan_type: str, filename: str) -> bool:
10991105
return not filename.endswith(consts.SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE)
11001106

11011107

1108+
def _generate_document(file: str, scan_type: str, content: str, is_git_diff: bool) -> Document:
1109+
if _is_iac(scan_type) and _is_tfplan_file(file, content):
1110+
return _handle_tfplan_file(file, content, is_git_diff)
1111+
return Document(file, content, is_git_diff)
1112+
1113+
1114+
def _handle_tfplan_file(file: str, content: str, is_git_diff: bool) -> Document:
1115+
document_name = _generate_tfplan_document_name(file)
1116+
tf_content = tf_content_generator.generate_tf_content_from_tfplan(file, content)
1117+
return Document(document_name, tf_content, is_git_diff)
1118+
1119+
1120+
def _generate_tfplan_document_name(path: str) -> str:
1121+
document_name = change_filename_extension(path, 'tf')
1122+
timestamp = int(time.time())
1123+
return f'{timestamp}-{document_name}'
1124+
1125+
1126+
def _is_iac(scan_type: str) -> bool:
1127+
return scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE
1128+
1129+
1130+
def _is_tfplan_file(file: str, content: str) -> bool:
1131+
if not file.endswith('.json'):
1132+
return False
1133+
tf_plan = load_json(content)
1134+
if not isinstance(tf_plan, dict):
1135+
return False
1136+
return 'resource_changes' in tf_plan
1137+
1138+
11021139
def _does_file_exceed_max_size_limit(filename: str) -> bool:
11031140
return get_file_size(filename) > consts.FILE_MAX_SIZE_LIMIT_IN_BYTES
11041141

@@ -1157,6 +1194,14 @@ def _handle_exception(context: click.Context, e: Exception, *, return_exception:
11571194
'Please try ignoring irrelevant paths using the `cycode ignore --by-path` command '
11581195
'and execute the scan again',
11591196
),
1197+
custom_exceptions.TfplanKeyError: CliError(
1198+
soft_fail=True,
1199+
code='key_error',
1200+
message=f'\n{e!s}\n'
1201+
'A crucial field is missing in your terraform plan file. '
1202+
'Please make sure that your file is well formed '
1203+
'and execute the scan again',
1204+
),
11601205
InvalidGitRepositoryError: CliError(
11611206
soft_fail=False,
11621207
code='invalid_git_error',

cycode/cli/exceptions/custom_exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,12 @@ def __init__(self, error_message: str) -> None:
5555

5656
def __str__(self) -> str:
5757
return f'Something went wrong during the authentication process, error message: {self.error_message}'
58+
59+
60+
class TfplanKeyError(CycodeError):
61+
def __init__(self, file_path: str) -> None:
62+
self.file_path = file_path
63+
super().__init__()
64+
65+
def __str__(self) -> str:
66+
return f'Error occurred while parsing terraform plan file. Path: {self.file_path}'
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import json
2+
from typing import List
3+
4+
from cycode.cli.exceptions.custom_exceptions import TfplanKeyError
5+
from cycode.cli.models import ResourceChange
6+
from cycode.cli.utils.path_utils import load_json
7+
8+
ACTIONS_TO_OMIT_RESOURCE = ['delete']
9+
10+
11+
def generate_tf_content_from_tfplan(filename: str, tfplan: str) -> str:
12+
planned_resources = _extract_resources(tfplan, filename)
13+
return _generate_tf_content(planned_resources)
14+
15+
16+
def _generate_tf_content(resource_changes: List[ResourceChange]) -> str:
17+
tf_content = ''
18+
for resource_change in resource_changes:
19+
if not any(item in resource_change.actions for item in ACTIONS_TO_OMIT_RESOURCE):
20+
tf_content += _generate_resource_content(resource_change)
21+
return tf_content
22+
23+
24+
def _generate_resource_content(resource_change: ResourceChange) -> str:
25+
resource_content = f'resource "{resource_change.resource_type}" "{resource_change.name}" {{\n'
26+
if resource_change.values is not None:
27+
for key, value in resource_change.values.items():
28+
resource_content += f' {key} = {json.dumps(value)}\n'
29+
resource_content += '}\n\n'
30+
return resource_content
31+
32+
33+
def _extract_resources(tfplan: str, filename: str) -> List[ResourceChange]:
34+
tfplan_json = load_json(tfplan)
35+
resources: List[ResourceChange] = []
36+
try:
37+
resource_changes = tfplan_json['resource_changes']
38+
for resource_change in resource_changes:
39+
resources.append(
40+
ResourceChange(
41+
resource_type=resource_change['type'],
42+
name=resource_change['name'],
43+
actions=resource_change['change']['actions'],
44+
values=resource_change['change']['after'],
45+
)
46+
)
47+
except (KeyError, TypeError) as e:
48+
raise TfplanKeyError(filename) from e
49+
return resources

cycode/cli/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from dataclasses import dataclass
12
from enum import Enum
23
from typing import Dict, List, NamedTuple, Optional, Type
34

@@ -63,3 +64,14 @@ class LocalScanResult(NamedTuple):
6364
issue_detected: bool
6465
detections_count: int
6566
relevant_detections_count: int
67+
68+
69+
@dataclass
70+
class ResourceChange:
71+
resource_type: str
72+
name: str
73+
actions: List[str]
74+
values: Dict[str, str]
75+
76+
def __repr__(self) -> str:
77+
return f'resource_type: {self.resource_type}, name: {self.name}'

cycode/cli/utils/path_utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import os
23
from functools import lru_cache
34
from typing import AnyStr, Iterable, List, Optional
@@ -71,6 +72,10 @@ def get_file_dir(path: str) -> str:
7172
return os.path.dirname(path)
7273

7374

75+
def get_immediate_subdirectories(path: str) -> List[str]:
76+
return [f.name for f in os.scandir(path) if f.is_dir()]
77+
78+
7479
def join_paths(path: str, filename: str) -> str:
7580
return os.path.join(path, filename)
7681

@@ -81,3 +86,15 @@ def get_file_content(file_path: str) -> Optional[AnyStr]:
8186
return f.read()
8287
except (FileNotFoundError, UnicodeDecodeError):
8388
return None
89+
90+
91+
def load_json(txt: str) -> Optional[dict]:
92+
try:
93+
return json.loads(txt)
94+
except json.JSONDecodeError:
95+
return None
96+
97+
98+
def change_filename_extension(filename: str, extension: str) -> str:
99+
base_name, _ = os.path.splitext(filename)
100+
return f'{base_name}.{extension}'

tests/cli/helpers/__init__.py

Whitespace-only changes.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import os
2+
3+
from cycode.cli.helpers import tf_content_generator
4+
from cycode.cli.utils.path_utils import get_file_content, get_immediate_subdirectories
5+
from tests.conftest import TEST_FILES_PATH
6+
7+
_PATH_TO_EXAMPLES = os.path.join(TEST_FILES_PATH, 'tf_content_generator_files')
8+
9+
10+
def test_generate_tf_content_from_tfplan() -> None:
11+
examples_directories = get_immediate_subdirectories(_PATH_TO_EXAMPLES)
12+
for example in examples_directories:
13+
tfplan_content = get_file_content(os.path.join(_PATH_TO_EXAMPLES, example, 'tfplan.json'))
14+
tf_expected_content = get_file_content(os.path.join(_PATH_TO_EXAMPLES, example, 'tf_content.txt'))
15+
tf_content = tf_content_generator.generate_tf_content_from_tfplan(example, tfplan_content)
16+
assert tf_content == tf_expected_content

tests/cli/test_code_scanner.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
from git import InvalidGitRepositoryError
88
from requests import Response
99

10-
from cycode.cli.code_scanner import _handle_exception, _is_file_relevant_for_sca_scan
10+
from cycode.cli import consts
11+
from cycode.cli.code_scanner import _generate_document, _handle_exception, _is_file_relevant_for_sca_scan
1112
from cycode.cli.exceptions import custom_exceptions
13+
from cycode.cli.models import Document
1214

1315
if TYPE_CHECKING:
1416
from _pytest.monkeypatch import MonkeyPatch
@@ -26,6 +28,7 @@ def ctx() -> click.Context:
2628
(custom_exceptions.ScanAsyncError('msg'), True),
2729
(custom_exceptions.HttpUnauthorizedError('msg', Response()), True),
2830
(custom_exceptions.ZipTooLargeError(1000), True),
31+
(custom_exceptions.TfplanKeyError('msg'), True),
2932
(InvalidGitRepositoryError(), None),
3033
],
3134
)
@@ -76,3 +79,60 @@ def test_is_file_relevant_for_sca_scan() -> None:
7679
assert _is_file_relevant_for_sca_scan(path) is True
7780
path = os.path.join('some_package', 'package.lock')
7881
assert _is_file_relevant_for_sca_scan(path) is True
82+
83+
84+
def test_generate_document() -> None:
85+
is_git_diff = False
86+
87+
path = 'path/to/nowhere.txt'
88+
content = 'nothing important here'
89+
90+
non_iac_document = Document(path, content, is_git_diff)
91+
generated_document = _generate_document(path, consts.SCA_SCAN_TYPE, content, is_git_diff)
92+
93+
assert non_iac_document.path == generated_document.path
94+
assert non_iac_document.content == generated_document.content
95+
assert non_iac_document.is_git_diff_format == generated_document.is_git_diff_format
96+
97+
path = 'path/to/nowhere.tf'
98+
content = """provider "aws" {
99+
profile = "chili"
100+
region = "us-east-1"
101+
}
102+
103+
resource "aws_s3_bucket" "chili-env-var-test" {
104+
bucket = "chili-env-var-test"
105+
}"""
106+
107+
iac_document = Document(path, content, is_git_diff)
108+
generated_document = _generate_document(path, consts.INFRA_CONFIGURATION_SCAN_TYPE, content, is_git_diff)
109+
assert iac_document.path == generated_document.path
110+
assert iac_document.content == generated_document.content
111+
assert iac_document.is_git_diff_format == generated_document.is_git_diff_format
112+
113+
content = """
114+
{
115+
"resource_changes":[
116+
{
117+
"type":"aws_s3_bucket_public_access_block",
118+
"name":"efrat-env-var-test",
119+
"change":{
120+
"actions":[
121+
"create"
122+
],
123+
"after":{
124+
"block_public_acls":false,
125+
"block_public_policy":true,
126+
"ignore_public_acls":false,
127+
"restrict_public_buckets":true
128+
}
129+
}
130+
]
131+
}
132+
"""
133+
134+
generated_tfplan_document = _generate_document(path, consts.INFRA_CONFIGURATION_SCAN_TYPE, content, is_git_diff)
135+
136+
assert type(generated_tfplan_document) == Document
137+
assert generated_tfplan_document.path.endswith('.tf')
138+
assert generated_tfplan_document.is_git_diff_format == is_git_diff

0 commit comments

Comments
 (0)