from __future__ import annotations from dataclasses import asdict import pytest def test_scan_repository_reports_only_location_and_rule(tmp_path): from gyxx_flow.security.scanner import scan_repository first_value = "not-a-real-" + "password-7f31" second_value = "not-a-real-" + "url-secret-91ac" private_key_header = "-----BEGIN " + "PRIVATE KEY-----" source = tmp_path / "src" / "settings.py" source.parent.mkdir() source.write_text( "DEBUG = True\n" f'DATABASE_PASSWORD = "{first_value}"\n' f'endpoint = "https://service-user:{second_value}@example.invalid/api"\n' f'key = "{private_key_header}"\n', encoding="utf-8", ) findings = scan_repository(tmp_path) assert [asdict(finding) for finding in findings] == [ {"file": "src/settings.py", "line": 2, "rule": "plaintext-credential"}, {"file": "src/settings.py", "line": 3, "rule": "credential-in-url"}, {"file": "src/settings.py", "line": 4, "rule": "private-key-material"}, ] rendered = repr(findings) assert first_value not in rendered assert second_value not in rendered assert set(asdict(findings[0])) == {"file", "line", "rule"} @pytest.mark.parametrize("directory", ["var", ".git", ".venv", ".learnings"]) def test_scan_repository_excludes_runtime_and_tool_directories(tmp_path, directory): from gyxx_flow.security.scanner import scan_repository value = "not-a-real-" + "excluded-secret-18d2" source = tmp_path / directory / "nested" / "settings.py" source.parent.mkdir(parents=True) source.write_text(f'API_KEY = "{value}"\n', encoding="utf-8") assert scan_repository(tmp_path) == [] def test_scan_repository_ignores_empty_and_externalized_values(tmp_path): from gyxx_flow.security.scanner import scan_repository password_name = "DATABASE_" + "PASSWORD" api_key_name = "API_" + "KEY" client_secret_name = "CLIENT_" + "SECRET" access_token_name = "ACCESS_" + "TOKEN" source = tmp_path / "config" / "settings.env" source.parent.mkdir() source.write_text( f"{password_name}=\n" f"{api_key_name}=${{GYXX_API_KEY}}\n" f"{client_secret_name}=$GYXX_CLIENT_SECRET\n" f"{access_token_name}=os.getenv('GYXX_ACCESS_TOKEN')\n", encoding="utf-8", ) assert scan_repository(tmp_path) == [] def test_scan_repository_detects_common_application_secret_names(tmp_path): from gyxx_flow.security.scanner import scan_repository app_secret_name = "FEISHU_APP_" + "SECRET" secret_key_name = "DJANGO_" + "SECRET_KEY" first_value = "not-a-real-" + "application-secret-20f9" second_value = "not-a-real-" + "signing-secret-52bb" source = tmp_path / "config" / "application.env" source.parent.mkdir() source.write_text( f"{app_secret_name}={first_value}\n" f"{secret_key_name}={second_value}\n", encoding="utf-8", ) assert [asdict(finding) for finding in scan_repository(tmp_path)] == [ {"file": "config/application.env", "line": 1, "rule": "plaintext-credential"}, {"file": "config/application.env", "line": 2, "rule": "plaintext-credential"}, ] def test_scan_repository_ignores_declarations_references_and_comparisons(tmp_path): from gyxx_flow.security.scanner import scan_repository password_name = "DATABASE_" + "PASSWORD" api_key_name = "API_" + "KEY" client_secret_name = "CLIENT_" + "SECRET" source = tmp_path / "src" / "model.py" source.parent.mkdir() source.write_text( f"{password_name}: str\n" f"{api_key_name}: SecretStr\n" f"{client_secret_name} = settings.client_secret\n" f"if {password_name} == candidate:\n pass\n", encoding="utf-8", ) assert scan_repository(tmp_path) == [] def test_scan_repository_rejects_a_non_directory_root(tmp_path): from gyxx_flow.security.scanner import scan_repository target = tmp_path / "settings.py" target.write_text("DEBUG = True\n", encoding="utf-8") with pytest.raises(NotADirectoryError): scan_repository(target) def test_scan_repository_detects_multiline_environment_secret_default(tmp_path): from gyxx_flow.security.scanner import scan_repository source = tmp_path / "collector.py" source.write_text( 'API_KEY = os.getenv(\n "SERVICE_API_KEY",\n' ' "0123456789abcdef0123456789abcdef",\n)\n', encoding="utf-8", ) findings = scan_repository(tmp_path) assert [(item.line, item.rule) for item in findings] == [ (1, "hardcoded-long-hex-credential") ]