225 lines
7.2 KiB
Python
225 lines
7.2 KiB
Python
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", "build", "dist", "demo.egg-info"],
|
|
)
|
|
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_ignores_runtime_secret_subscript_references(tmp_path):
|
|
from gyxx_flow.security.scanner import scan_repository
|
|
|
|
source = tmp_path / "src" / "runtime.py"
|
|
source.parent.mkdir()
|
|
sensitive_name = "pass" + "word"
|
|
token_name = "access_" + "token"
|
|
source.write_text(
|
|
f'{sensitive_name} = runtime_config["{sensitive_name}"]\n'
|
|
f"{token_name} = settings.{token_name}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
assert scan_repository(tmp_path) == []
|
|
|
|
|
|
def test_scan_repository_detects_bare_identifier_shaped_plaintext(tmp_path):
|
|
from gyxx_flow.security.scanner import scan_repository
|
|
|
|
source = tmp_path / "config" / "application.env"
|
|
source.parent.mkdir()
|
|
credential_name = "DATABASE_" + "PASSWORD"
|
|
credential_value = "sword" + "fish123"
|
|
source.write_text(
|
|
f"{credential_name}={credential_value}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
assert [(item.line, item.rule) for item in scan_repository(tmp_path)] == [
|
|
(1, "plaintext-credential")
|
|
]
|
|
|
|
|
|
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")
|
|
]
|
|
|
|
|
|
def test_scan_repository_detects_plaintext_environment_fallback(tmp_path):
|
|
from gyxx_flow.security.scanner import scan_repository
|
|
|
|
password_name = "SYCM_" + "PASSWORD"
|
|
fallback = "not-a-real-" + "browser-secret-31c8"
|
|
source = tmp_path / "collector.py"
|
|
source.write_text(
|
|
f'{password_name} = os.getenv("{password_name}", "{fallback}")\n',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
findings = scan_repository(tmp_path)
|
|
|
|
assert [(item.line, item.rule) for item in findings] == [
|
|
(1, "plaintext-environment-fallback")
|
|
]
|
|
|
|
|
|
def test_scan_repository_detects_identifier_shaped_environment_fallback(tmp_path):
|
|
from gyxx_flow.security.scanner import scan_repository
|
|
|
|
credential_name = "PASS" + "WORD"
|
|
fallback = "sword" + "fish123"
|
|
source = tmp_path / "collector.py"
|
|
source.write_text(
|
|
f'{credential_name} = os.getenv("{credential_name}", "{fallback}")\n',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
findings = scan_repository(tmp_path)
|
|
|
|
assert [(item.line, item.rule) for item in findings] == [
|
|
(1, "plaintext-environment-fallback")
|
|
]
|
|
|
|
|
|
def test_scan_repository_detects_multiline_plaintext_environment_fallback(tmp_path):
|
|
from gyxx_flow.security.scanner import scan_repository
|
|
|
|
credential_name = "PASS" + "WORD"
|
|
fallback = "sword" + "fish123"
|
|
source = tmp_path / "collector.py"
|
|
source.write_text(
|
|
f'{credential_name} = os.getenv(\n "{credential_name}",\n'
|
|
f' "{fallback}",\n)\n',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
findings = scan_repository(tmp_path)
|
|
|
|
assert [(item.line, item.rule) for item in findings] == [
|
|
(1, "plaintext-environment-fallback")
|
|
]
|