From a4d07678b400d20a2840fe6778df0a7e70f0da8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 14 Oct 2026 13:55:43 +0200 Subject: [PATCH] fix(outputs): neutralise injection in CSV and HTML reports (#12963) --- .../csv-formula-injection.security.md | 1 + .../html-compliance-escape.security.md | 1 + .../outputs/compliance/compliance_output.py | 6 ++- .../compliance/universal/universal_output.py | 8 ++- prowler/lib/outputs/csv/csv.py | 6 ++- prowler/lib/outputs/html/html.py | 2 +- prowler/lib/outputs/utils.py | 21 ++++++++ .../lib/quick_inventory/quick_inventory.py | 3 +- .../compliance/generic/generic_aws_test.py | 22 ++++++++ .../universal/universal_output_test.py | 28 ++++++++++ tests/lib/outputs/csv/csv_test.py | 24 +++++++++ tests/lib/outputs/html/html_test.py | 17 ++++++ tests/lib/outputs/outputs_test.py | 49 +++++++++++++++++ .../quick_inventory/quick_inventory_test.py | 52 +++++++++++++++++++ 14 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 prowler/changelog.d/csv-formula-injection.security.md create mode 100644 prowler/changelog.d/html-compliance-escape.security.md create mode 100644 tests/providers/aws/lib/quick_inventory/quick_inventory_test.py diff --git a/prowler/changelog.d/csv-formula-injection.security.md b/prowler/changelog.d/csv-formula-injection.security.md new file mode 100644 index 0000000000..c8fd6b9efd --- /dev/null +++ b/prowler/changelog.d/csv-formula-injection.security.md @@ -0,0 +1 @@ +CSV outputs (findings, compliance and AWS quick inventory) prefix cells starting with `=`, `+`, `-`, `@`, tab or carriage return with a single quote so spreadsheet applications treat them as text instead of formulas; signed numbers such as `-1` are left untouched diff --git a/prowler/changelog.d/html-compliance-escape.security.md b/prowler/changelog.d/html-compliance-escape.security.md new file mode 100644 index 0000000000..98b4b12a04 --- /dev/null +++ b/prowler/changelog.d/html-compliance-escape.security.md @@ -0,0 +1 @@ +HTML report compliance column is now escaped, so framework names and requirement IDs containing markup are shown as text instead of being rendered by the browser diff --git a/prowler/lib/outputs/compliance/compliance_output.py b/prowler/lib/outputs/compliance/compliance_output.py index f4f84561f4..60fc5d6749 100644 --- a/prowler/lib/outputs/compliance/compliance_output.py +++ b/prowler/lib/outputs/compliance/compliance_output.py @@ -6,6 +6,7 @@ from prowler.lib.check.compliance_models import Compliance from prowler.lib.logger import logger from prowler.lib.outputs.finding import Finding from prowler.lib.outputs.output import Output +from prowler.lib.outputs.utils import sanitize_csv_value class ComplianceOutput(Output): @@ -83,7 +84,10 @@ class ComplianceOutput(Output): csv_writer.writeheader() for finding in self._data: csv_writer.writerow( - {k.upper(): v for k, v in finding.dict().items()} + { + k.upper(): sanitize_csv_value(v) + for k, v in finding.dict().items() + } ) if self.close_file or self._from_cli: self._file_descriptor.close() diff --git a/prowler/lib/outputs/compliance/universal/universal_output.py b/prowler/lib/outputs/compliance/universal/universal_output.py index 9c7c76da47..505e855b57 100644 --- a/prowler/lib/outputs/compliance/universal/universal_output.py +++ b/prowler/lib/outputs/compliance/universal/universal_output.py @@ -11,6 +11,7 @@ from prowler.lib.check.compliance_config_eval import ( ) from prowler.lib.check.compliance_models import ComplianceFramework from prowler.lib.logger import logger +from prowler.lib.outputs.utils import sanitize_csv_value from prowler.lib.utils.utils import open_file if TYPE_CHECKING: @@ -318,7 +319,12 @@ class UniversalComplianceOutput: if self._file_descriptor.tell() == 0: csv_writer.writeheader() for row in self._data: - csv_writer.writerow({k.upper(): v for k, v in row.dict().items()}) + csv_writer.writerow( + { + k.upper(): sanitize_csv_value(v) + for k, v in row.dict().items() + } + ) if self.close_file or self._from_cli: self._file_descriptor.close() except Exception as error: diff --git a/prowler/lib/outputs/csv/csv.py b/prowler/lib/outputs/csv/csv.py index bcb50d9433..1c10b20af7 100644 --- a/prowler/lib/outputs/csv/csv.py +++ b/prowler/lib/outputs/csv/csv.py @@ -4,7 +4,7 @@ from typing import List from prowler.lib.logger import logger from prowler.lib.outputs.finding import Finding from prowler.lib.outputs.output import Output -from prowler.lib.outputs.utils import unroll_dict, unroll_list +from prowler.lib.outputs.utils import sanitize_csv_value, unroll_dict, unroll_list class CSV(Output): @@ -106,7 +106,9 @@ class CSV(Output): if self._file_descriptor.tell() == 0: csv_writer.writeheader() for finding in self._data: - csv_writer.writerow(finding) + csv_writer.writerow( + {k: sanitize_csv_value(v) for k, v in finding.items()} + ) if self.close_file or self._from_cli: self._file_descriptor.close() except Exception as error: diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index 60428bede7..da3faa8b1c 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -116,7 +116,7 @@ class HTML(Output): {str(escape(finding.status_extended)).replace("_", "_")}

{HTML.process_markdown(str(escape(finding.metadata.Risk)))}

{HTML.process_markdown(str(escape(finding.metadata.Remediation.Recommendation.Text)))}

-

{parse_html_string(unroll_dict(finding.compliance, separator=": "))}

+

{parse_html_string(str(escape(unroll_dict(finding.compliance, separator=": "))))}

""") except Exception as error: diff --git a/prowler/lib/outputs/utils.py b/prowler/lib/outputs/utils.py index 9750c4f7e2..de0ffce5c3 100644 --- a/prowler/lib/outputs/utils.py +++ b/prowler/lib/outputs/utils.py @@ -1,3 +1,6 @@ +from math import isfinite + + def unroll_list(listed_items: list, separator: str = "|") -> str: """ Unrolls a list of items into a single string, separated by a specified separator. @@ -199,3 +202,21 @@ def parse_html_string(str: str) -> str: string += f"\n•{elem}\n" return string + + +def sanitize_csv_value(value): + """Prefix a cell with `'` when a spreadsheet would evaluate it as a formula; numbers are left as they are.""" + if not isinstance(value, str) or not ( + value.startswith(("\t", "\r")) + or value.lstrip().startswith(("=", "+", "-", "@")) + ): + return value + try: + number = float(value) + except ValueError: + return f"'{value}" + # float() also accepts nan/inf and whitespace-padded forms, which a spreadsheet + # renders as text rather than as a number + if isfinite(number) and value == value.strip(): + return value + return f"'{value}" diff --git a/prowler/providers/aws/lib/quick_inventory/quick_inventory.py b/prowler/providers/aws/lib/quick_inventory/quick_inventory.py index 8ffdd80e7b..07327aac76 100644 --- a/prowler/providers/aws/lib/quick_inventory/quick_inventory.py +++ b/prowler/providers/aws/lib/quick_inventory/quick_inventory.py @@ -14,6 +14,7 @@ from prowler.config.config import ( output_file_timestamp, ) from prowler.lib.logger import logger +from prowler.lib.outputs.utils import sanitize_csv_value from prowler.providers.aws.aws_provider import AwsProvider from prowler.providers.aws.lib.arn.models import get_arn_resource_type @@ -293,7 +294,7 @@ def create_output(resources: list, provider: AwsProvider, args): header = data.keys() csv_writer.writerow(header) count += 1 - csv_writer.writerow(data.values()) + csv_writer.writerow([sanitize_csv_value(v) for v in data.values()]) csv_file.close() print( diff --git a/tests/lib/outputs/compliance/generic/generic_aws_test.py b/tests/lib/outputs/compliance/generic/generic_aws_test.py index 335a9ad17d..36ac977715 100644 --- a/tests/lib/outputs/compliance/generic/generic_aws_test.py +++ b/tests/lib/outputs/compliance/generic/generic_aws_test.py @@ -136,6 +136,28 @@ class TestAWSGenericCompliance: assert content == expected_csv + def test_batch_write_neutralises_formula_initiators(self): + mock_file = StringIO() + findings = [ + generate_finding_output( + status_extended="=cmd", + resource_uid="-1", + compliance={"NIST-800-53-Revision-4": "ac_2_4"}, + ) + ] + output = GenericCompliance(findings, NIST_800_53_REVISION_4_AWS) + output._file_descriptor = mock_file + + with patch.object(mock_file, "close", return_value=None): + output.batch_write_data_to_file() + + mock_file.seek(0) + header, row, _ = mock_file.read().splitlines() + cells = dict(zip(header.split(";"), row.split(";"))) + + assert cells["STATUSEXTENDED"] == "'=cmd" + assert cells["RESOURCEID"] == "-1" + def test_csv_row_count_matches_framework_checks_not_stored_compliance(self): """Regression test for PROWLER-1763. diff --git a/tests/lib/outputs/compliance/universal/universal_output_test.py b/tests/lib/outputs/compliance/universal/universal_output_test.py index dd951cf272..00f577060a 100644 --- a/tests/lib/outputs/compliance/universal/universal_output_test.py +++ b/tests/lib/outputs/compliance/universal/universal_output_test.py @@ -228,6 +228,34 @@ class TestCSVFileWrite: assert "REQUIREMENTS_ATTRIBUTES_SECTION" in content assert "IAM" in content + def test_batch_write_neutralises_formula_initiators(self, tmp_path): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ), + ] + fw = _make_framework(reqs, [AttributeMetadata(key="Section", type="str")]) + + finding = _make_finding("check_a", "PASS", {"TestFW-1.0": ["1.1"]}) + finding.status_extended = "=cmd" + finding.resource_name = "-1" + filepath = str(tmp_path / "test.csv") + + output = UniversalComplianceOutput( + findings=[finding], framework=fw, file_path=filepath + ) + output.batch_write_data_to_file() + + with open(filepath, "r") as f: + header, row = f.read().splitlines() + cells = dict(zip(header.split(";"), row.split(";"))) + + assert cells["STATUSEXTENDED"] == "'=cmd" + assert cells["RESOURCENAME"] == "-1" + class TestNoFindings: def test_empty_findings_no_data(self, tmp_path): diff --git a/tests/lib/outputs/csv/csv_test.py b/tests/lib/outputs/csv/csv_test.py index b2b5d390df..dd132ad593 100644 --- a/tests/lib/outputs/csv/csv_test.py +++ b/tests/lib/outputs/csv/csv_test.py @@ -1,4 +1,5 @@ import tempfile +from csv import DictReader from datetime import datetime from io import StringIO, TextIOWrapper from typing import List @@ -131,6 +132,29 @@ class TestCSV: assert content == expected_csv + def test_csv_write_neutralises_formula_initiators(self): + mock_file = StringIO() + findings = [ + generate_finding_output( + resource_name='=HYPERLINK("http://attacker.example")', + resource_details="-1", + resource_tags={"=cmd": "x", "env": "prod"}, + ) + ] + + output = CSV(findings) + output._file_descriptor = mock_file + + with patch.object(mock_file, "close", return_value=None): + output.batch_write_data_to_file() + + mock_file.seek(0) + cells = next(DictReader(mock_file, delimiter=";")) + + assert cells["RESOURCE_NAME"] == '\'=HYPERLINK("http://attacker.example")' + assert cells["RESOURCE_DETAILS"] == "-1" + assert cells["RESOURCE_TAGS"] == "'=cmd=x | env=prod" + def test_batch_write_data_to_file_without_findings(self): assert not CSV([])._file_descriptor diff --git a/tests/lib/outputs/html/html_test.py b/tests/lib/outputs/html/html_test.py index 1590d29742..6c39e43249 100644 --- a/tests/lib/outputs/html/html_test.py +++ b/tests/lib/outputs/html/html_test.py @@ -910,6 +910,23 @@ class TestHTML: assert "•key&<>'"<img" in output_data assert "=value&<>'"<img" in output_data + def test_transform_escapes_compliance(self): + xss_payload = '' + findings = [ + generate_finding_output( + compliance={f"Framework{xss_payload}-1.0": [xss_payload, "1.1"]}, + ) + ] + + output_data = HTML(findings).data[0] + + assert xss_payload not in output_data + assert ( + "•Framework<img src=x " + "onerror="window.PROWLER_COMPLIANCE_XSS=1">-1.0: " + "<img src=x onerror="window.PROWLER_COMPLIANCE_XSS=1">, 1.1" + ) in output_data + def test_transform_escapes_metadata_fields(self): finding = generate_finding_output() finding.metadata.Severity = MagicMock(value='') diff --git a/tests/lib/outputs/outputs_test.py b/tests/lib/outputs/outputs_test.py index fad6f4d286..b404e1c032 100644 --- a/tests/lib/outputs/outputs_test.py +++ b/tests/lib/outputs/outputs_test.py @@ -13,6 +13,7 @@ from prowler.lib.outputs.outputs import ( from prowler.lib.outputs.utils import ( parse_html_string, parse_json_tags, + sanitize_csv_value, unroll_dict, unroll_dict_to_list, unroll_list, @@ -1313,3 +1314,51 @@ class TestReport: with mock.patch("builtins.print") as mocked_print: report([finding], provider, output_options) mocked_print.assert_called() + + +class TestSanitizeCSVValue: + @pytest.mark.parametrize("initiator", ["=", "+", "-", "@", "\t", "\r"]) + def test_leading_formula_initiator_is_prefixed(self, initiator): + assert sanitize_csv_value(f"{initiator}cmd") == f"'{initiator}cmd" + + def test_value_containing_equals_is_untouched(self): + assert sanitize_csv_value("key=value") == "key=value" + + @pytest.mark.parametrize("number", ["-1", "+1", "-1.5", "-1e3"]) + def test_signed_number_is_untouched(self, number): + assert sanitize_csv_value(number) == number + + @pytest.mark.parametrize("value", [None, True, -1, -1.5, ""]) + def test_non_string_and_empty_values_are_untouched(self, value): + assert sanitize_csv_value(value) == value + + @pytest.mark.parametrize( + "value", ["-nan", "+nan", "-inf", "+inf", "-infinity", "+Infinity"] + ) + def test_non_finite_float_literal_is_prefixed(self, value): + assert sanitize_csv_value(value) == f"'{value}" + + @pytest.mark.parametrize("value", ["-1 ", "+1\t", "-1\n"]) + def test_padded_number_is_prefixed(self, value): + assert sanitize_csv_value(value) == f"'{value}" + + @pytest.mark.parametrize( + "payload", + [ + "=cmd|' /C calc'!A0", + "+cmd|' /C calc'!A0", + "-1+1|cmd|' /C calc'!A0", + "@SUM(1+1)*cmd|' /C calc'!A0", + "-0x1", + ], + ) + def test_formula_payload_is_prefixed(self, payload): + assert sanitize_csv_value(payload) == f"'{payload}" + + @pytest.mark.parametrize("value", [" =cmd", "\n=cmd", " -1+1|cmd", " \n@SUM(1)"]) + def test_whitespace_before_formula_initiator_is_prefixed(self, value): + assert sanitize_csv_value(value) == f"'{value}" + + @pytest.mark.parametrize("value", [" ", "\n", " text"]) + def test_whitespace_without_initiator_is_untouched(self, value): + assert sanitize_csv_value(value) == value diff --git a/tests/providers/aws/lib/quick_inventory/quick_inventory_test.py b/tests/providers/aws/lib/quick_inventory/quick_inventory_test.py new file mode 100644 index 0000000000..52375c67b8 --- /dev/null +++ b/tests/providers/aws/lib/quick_inventory/quick_inventory_test.py @@ -0,0 +1,52 @@ +import csv +from unittest.mock import MagicMock + +from prowler.providers.aws.lib.quick_inventory.quick_inventory import create_output + + +def _args(tmp_path): + args = MagicMock() + args.output_directory = str(tmp_path) + args.output_filename = "inventory" + return args + + +def _provider(): + provider = MagicMock() + provider.identity.account = "123456789012" + return provider + + +def _rows(tmp_path): + with open(tmp_path / "inventory.csv", newline="") as handle: + return list(csv.reader(handle)) + + +class TestQuickInventoryCsvOutput: + def test_formula_initiator_in_tags_is_neutralised(self, tmp_path): + resources = [ + { + "arn": "arn:aws:s3:eu-west-1:123456789012:bucket-one", + "tags": '=cmd|" /C calc"!A0', + } + ] + + create_output(resources, _provider(), _args(tmp_path)) + + header, row = _rows(tmp_path) + assert row[header.index("AWS_Tags")] == '\'=cmd|" /C calc"!A0' + + def test_header_and_ordinary_values_are_untouched(self, tmp_path): + resources = [ + { + "arn": "arn:aws:s3:eu-west-1:123456789012:bucket-one", + "tags": "env=prod", + } + ] + + create_output(resources, _provider(), _args(tmp_path)) + + header, row = _rows(tmp_path) + assert header[0] == "AWS_AccountID" + assert row[header.index("AWS_Tags")] == "env=prod" + assert row[header.index("AWS_Region")] == "eu-west-1"