224 lines
9.4 KiB
Python
224 lines
9.4 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app.models import Severity
|
|
from app.scanner import scan_repository
|
|
|
|
|
|
def _write_good_repo(root: Path) -> None:
|
|
(root / "README.md").write_text("# Demo\n", encoding="utf-8")
|
|
(root / "LICENSE").write_text("MIT\n", encoding="utf-8")
|
|
(root / ".gitignore").write_text(".env\n", encoding="utf-8")
|
|
(root / "src").mkdir()
|
|
(root / "tests").mkdir()
|
|
(root / ".gitea" / "workflows").mkdir(parents=True)
|
|
(root / ".gitea" / "workflows" / "check.yml").write_text("name: check\n", encoding="utf-8")
|
|
(root / "src" / "core.py").write_text("def answer():\n return 42\n", encoding="utf-8")
|
|
(root / "src" / "app.py").write_text("import src.core\n\ndef run():\n return src.core.answer()\n", encoding="utf-8")
|
|
(root / "tests" / "test_app.py").write_text("def test_app():\n assert True\n", encoding="utf-8")
|
|
|
|
|
|
def _realistic_fixture_secret() -> str:
|
|
parts = ["production", "secret", "value", "123456"]
|
|
return "-".join(parts)
|
|
|
|
|
|
def test_scan_collects_v1_signals_and_architecture(tmp_path: Path) -> None:
|
|
_write_good_repo(tmp_path)
|
|
|
|
report = scan_repository(str(tmp_path))
|
|
|
|
assert report.repository_name == tmp_path.name
|
|
assert report.metrics.source_files == 3
|
|
assert report.metrics.test_files == 1
|
|
assert report.languages["Python"] > 0
|
|
assert report.tests.ci_workflows == [".gitea/workflows/check.yml"]
|
|
assert report.release_gate is not None
|
|
assert report.release_gate.status in {"READY", "READY WITH WARNINGS"}
|
|
assert any(edge.source == "src/app.py" and edge.target == "src/core.py" for edge in report.architecture.edges)
|
|
assert any(item.path == "src/app.py" for item in report.hotspots)
|
|
assert 0 <= report.health_score <= 100
|
|
|
|
|
|
def test_scan_collects_debt_and_dependency_signals(tmp_path: Path) -> None:
|
|
_write_good_repo(tmp_path)
|
|
(tmp_path / "package.json").write_text(
|
|
'{"dependencies":{"react":"latest"},"devDependencies":{"vitest":"^3.0.0"}}',
|
|
encoding="utf-8",
|
|
)
|
|
(tmp_path / "src" / "app.py").write_text(
|
|
"# TODO improve this\n# FIXME remove workaround\nprint('hello')\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report = scan_repository(str(tmp_path))
|
|
|
|
assert report.metrics.todo == 1
|
|
assert report.metrics.fixme == 1
|
|
assert report.dependencies.declared_dependencies == 2
|
|
assert report.dependencies.unbounded_dependencies == 1
|
|
assert any(item.code == "dependency.unbounded" for item in report.findings)
|
|
|
|
|
|
def test_potential_secret_is_redacted_and_blocks_release(tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("# Demo\n", encoding="utf-8")
|
|
(tmp_path / ".gitignore").write_text("*.db\n", encoding="utf-8")
|
|
secret_name = "API_" + "KEY"
|
|
(tmp_path / "app.py").write_text(
|
|
f"{secret_name} = 'super-secret-value-123456'\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report = scan_repository(str(tmp_path))
|
|
secret_findings = [item for item in report.findings if item.code == "security.secret-assignment"]
|
|
|
|
assert report.release_readiness == "BLOCKED"
|
|
assert report.release_gate is not None
|
|
assert report.release_gate.status == "BLOCKED"
|
|
assert secret_findings
|
|
assert secret_findings[0].severity == Severity.BLOCKER
|
|
assert "super-secret-example-value" not in secret_findings[0].message
|
|
|
|
|
|
def test_secret_detection_scans_real_literals_in_tests_but_ignores_obvious_templates(tmp_path: Path) -> None:
|
|
_write_good_repo(tmp_path)
|
|
realistic_secret = _realistic_fixture_secret()
|
|
token_key = "to" + "ken"
|
|
(tmp_path / "src" / "settings.py").write_text(
|
|
"secret = os.getenv('APP_SECRET')\n" + f"api_key = '{realistic_secret}'\n",
|
|
encoding="utf-8",
|
|
)
|
|
(tmp_path / "tests" / "test_transport.py").write_text(
|
|
token_key + ' = "' + realistic_secret + '"\nresponse = b"token=test-token upstream body"\n',
|
|
encoding="utf-8",
|
|
)
|
|
(tmp_path / "README.md").write_text("# Demo\n\n`token=example-documentation-value`\n", encoding="utf-8")
|
|
(tmp_path / ".env.development.example").write_text("APP_SECRET=example-development-secret\n", encoding="utf-8")
|
|
|
|
report = scan_repository(str(tmp_path))
|
|
secret_findings = [item for item in report.findings if item.code == "security.secret-assignment"]
|
|
|
|
assert len(secret_findings) == 2
|
|
assert {item.path for item in secret_findings} == {"src/settings.py", "tests/test_transport.py"}
|
|
assert all(item.guidance and item.guidance.remediation_class == "security remediation" for item in secret_findings)
|
|
assert secret_findings[0].line == 2
|
|
assert not any(item.code == "security.env-committed" for item in report.findings)
|
|
|
|
|
|
def test_scan_budget_reports_coverage_without_turning_truncation_into_a_blocker(tmp_path: Path) -> None:
|
|
_write_good_repo(tmp_path)
|
|
for index in range(4):
|
|
(tmp_path / f"module_{index}.py").write_text("print('ok')\n", encoding="utf-8")
|
|
|
|
from app.models import ScanLimits
|
|
|
|
report = scan_repository(str(tmp_path), limits=ScanLimits(max_files=2, max_text_bytes_per_file=2_000_000, max_findings=20, max_secret_findings=5))
|
|
|
|
assert report.coverage is not None and report.coverage.truncated is True
|
|
assert report.coverage.skipped_files_by_budget > 0
|
|
assert any(check.key == "analysis_coverage" and check.state.value == "warn" for check in report.release_gate.checks)
|
|
assert any(item.code == "analysis.findings-truncated" for item in report.findings) is False
|
|
|
|
|
|
def test_configuration_literal_secret_remains_a_blocker(tmp_path: Path) -> None:
|
|
_write_good_repo(tmp_path)
|
|
realistic_secret = _realistic_fixture_secret()
|
|
token_key = "to" + "ken"
|
|
(tmp_path / "settings.json").write_text(
|
|
json.dumps({token_key: realistic_secret}) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report = scan_repository(str(tmp_path))
|
|
secret_findings = [item for item in report.findings if item.code == "security.secret-assignment"]
|
|
|
|
assert report.release_readiness == "BLOCKED"
|
|
assert [(item.path, item.line) for item in secret_findings] == [("settings.json", 1)]
|
|
|
|
|
|
def test_member_literal_secret_remains_a_blocker(tmp_path: Path) -> None:
|
|
_write_good_repo(tmp_path)
|
|
(tmp_path / "src" / "client.py").write_text(
|
|
"class Client:\n def __init__(self):\n self.api_key = 'production-secret-value-123456'\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report = scan_repository(str(tmp_path))
|
|
secret_findings = [item for item in report.findings if item.code == "security.secret-assignment"]
|
|
|
|
assert report.release_readiness == "BLOCKED"
|
|
assert [(item.path, item.line) for item in secret_findings] == [("src/client.py", 3)]
|
|
|
|
|
|
def test_documentation_only_repository_does_not_require_test_files(tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("# Catalog entry\n", encoding="utf-8")
|
|
(tmp_path / ".gitignore").write_text(".DS_Store\n", encoding="utf-8")
|
|
|
|
report = scan_repository(str(tmp_path))
|
|
|
|
assert report.metrics.source_files == 0
|
|
assert not any(item.code == "testing.none-detected" for item in report.findings)
|
|
|
|
|
|
def test_scan_skips_symlinked_files_outside_repository(tmp_path: Path) -> None:
|
|
_write_good_repo(tmp_path)
|
|
external = tmp_path.parent / f"{tmp_path.name}-outside.py"
|
|
external.write_text("API_KEY = 'outside-secret-example-value'\n", encoding="utf-8")
|
|
linked = tmp_path / "linked.py"
|
|
try:
|
|
linked.symlink_to(external)
|
|
except (NotImplementedError, OSError):
|
|
pytest.skip("symlinks are unavailable in this environment")
|
|
|
|
report = scan_repository(str(tmp_path))
|
|
|
|
assert report.metrics.source_files == 3
|
|
assert all(finding.path != "linked.py" for finding in report.findings)
|
|
|
|
|
|
def test_scan_skips_symlinked_directories_outside_repository(tmp_path: Path) -> None:
|
|
_write_good_repo(tmp_path)
|
|
external = tmp_path.parent / f"{tmp_path.name}-outside-dir"
|
|
external.mkdir()
|
|
(external / "outside.py").write_text("API_KEY = 'outside-secret-example-value'\n", encoding="utf-8")
|
|
linked = tmp_path / "linked"
|
|
try:
|
|
linked.symlink_to(external, target_is_directory=True)
|
|
except (NotImplementedError, OSError):
|
|
pytest.skip("directory symlinks are unavailable in this environment")
|
|
|
|
report = scan_repository(str(tmp_path))
|
|
|
|
assert report.metrics.source_files == 3
|
|
assert all(finding.path != "linked/outside.py" for finding in report.findings)
|
|
|
|
|
|
def test_malformed_dependency_manifests_are_reported_without_crashing(tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("# Demo\n", encoding="utf-8")
|
|
(tmp_path / ".gitignore").write_text("\n", encoding="utf-8")
|
|
(tmp_path / "package.json").write_text("[]", encoding="utf-8")
|
|
(tmp_path / "pyproject.toml").write_text("[project]\ndependencies = \"invalid\"\n", encoding="utf-8")
|
|
(tmp_path / "pom.xml").write_text("<project>", encoding="utf-8")
|
|
|
|
report = scan_repository(str(tmp_path))
|
|
codes = {finding.code for finding in report.findings}
|
|
|
|
assert "dependency.invalid-package-json" in codes
|
|
assert "dependency.invalid-pyproject" in codes
|
|
assert "dependency.invalid-pom" in codes
|
|
|
|
|
|
def test_empty_git_repository_does_not_fallback_to_untracked_files(tmp_path: Path) -> None:
|
|
(tmp_path / "stray.py").write_text("print('not tracked')\n", encoding="utf-8")
|
|
subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True)
|
|
|
|
report = scan_repository(str(tmp_path))
|
|
|
|
assert report.git.available is True
|
|
assert report.git.tracked_files == 0
|
|
assert report.metrics.files == 0
|
|
assert report.metrics.source_files == 0
|