168 lines
7.1 KiB
Python
168 lines
7.1 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
from uuid import uuid4
|
|
|
|
from packages.connector_kit.gitea import GiteaSettings
|
|
from packages.connector_kit.knowledge import (
|
|
DocumentNotFound,
|
|
InMemoryKnowledgeIndex,
|
|
PathEscapeError,
|
|
RegisteredSource,
|
|
)
|
|
from packages.connector_kit.redaction import safe_content
|
|
from packages.hub_contracts.dataplane import AliasRecord
|
|
from scripts.generate_sbom import generate
|
|
from scripts.security_scan import (
|
|
_BEARER,
|
|
_tracked_text_files,
|
|
scan_repository,
|
|
scan_secret_patterns,
|
|
)
|
|
from services.control_api.catalog.models import CatalogState
|
|
from services.control_api.catalog.planner import build_plan
|
|
from services.control_api.contextforge.models import GatewayDesiredState, GatewaySnapshot
|
|
|
|
|
|
class SecurityPlanTests(unittest.TestCase):
|
|
def test_bearer_secret_scan_does_not_cross_configuration_lines(self) -> None:
|
|
self.assertIsNone(_BEARER.search("HUB_CONTEXTFORGE_AUTH_SCHEME: Bearer\nNEXT_KEY=value"))
|
|
self.assertIsNotNone(_BEARER.search("Authorization: Bearer abcdefghijklmnopqrstuvwxyz1234"))
|
|
|
|
def test_static_security_scan_proves_compose_invariants_and_no_live_secret(self) -> None:
|
|
report = scan_repository()
|
|
self.assertEqual(report["status"], "pass")
|
|
self.assertEqual(report["compose"]["violations"], [])
|
|
self.assertEqual(report["secrets"]["findings"], [])
|
|
self.assertTrue(report["invariants"]["denyByDefault"])
|
|
|
|
def test_secret_scan_ignores_untracked_scratch_checkout_files(self) -> None:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
repository = Path(directory)
|
|
git = shutil.which("git")
|
|
self.assertIsNotNone(git)
|
|
assert git is not None
|
|
subprocess.run([git, "init", "--quiet"], cwd=repository, check=True) # noqa: S603
|
|
tracked = repository / "tracked.md"
|
|
tracked.write_text("ordinary tracked documentation\n", encoding="utf-8")
|
|
subprocess.run([git, "add", "tracked.md"], cwd=repository, check=True) # noqa: S603
|
|
tracked_secret = repository / "tracked-secret.md"
|
|
tracked_secret.write_text(
|
|
'password = "tracked-sensitive-value-9283746501"\n', encoding="utf-8"
|
|
)
|
|
subprocess.run([git, "add", "tracked-secret.md"], cwd=repository, check=True) # noqa: S603
|
|
scratch = repository / ".tmp" / "source-checkout"
|
|
scratch.mkdir(parents=True)
|
|
(scratch / "ignored.md").write_text(
|
|
'password = "untracked-sensitive-value-9283746501"\n', encoding="utf-8"
|
|
)
|
|
|
|
with patch("scripts.security_scan.ROOT", repository):
|
|
self.assertCountEqual(_tracked_text_files(), [tracked, tracked_secret])
|
|
findings = scan_secret_patterns()["findings"]
|
|
self.assertEqual(len(findings), 1)
|
|
self.assertEqual(findings[0]["path"], "tracked-secret.md")
|
|
|
|
def test_git_enumeration_failure_fails_closed(self) -> None:
|
|
error = subprocess.CalledProcessError(2, ["git", "ls-files", "-z"])
|
|
with patch("scripts.security_scan.subprocess.run", side_effect=error):
|
|
with self.assertRaises(subprocess.CalledProcessError):
|
|
_tracked_text_files()
|
|
|
|
def test_missing_git_fails_closed(self) -> None:
|
|
with patch("scripts.security_scan.shutil.which", return_value=None):
|
|
with self.assertRaises(FileNotFoundError):
|
|
_tracked_text_files()
|
|
|
|
def test_sbom_is_deterministic_and_contains_locked_dependencies_and_images(self) -> None:
|
|
report = generate()
|
|
self.assertEqual(report["bomFormat"], "CycloneDX")
|
|
self.assertEqual(report["metadata"]["component"]["name"], "itworx-mcp-hub")
|
|
self.assertTrue(report["metadata"]["sourceCommit"])
|
|
components = report["components"]
|
|
self.assertTrue(any(item["name"] == "fastapi" for item in components))
|
|
self.assertTrue(
|
|
any(
|
|
item["type"] == "container" and "@sha256:" in item["version"] for item in components
|
|
)
|
|
)
|
|
|
|
def test_prompt_injection_is_data_and_secret_content_is_rejected(self) -> None:
|
|
fixture = json.loads(
|
|
(
|
|
Path(__file__).resolve().parents[1]
|
|
/ "tests/fixtures/security/wp160-untrusted-inputs.json"
|
|
).read_text(encoding="utf-8")
|
|
)
|
|
content = fixture["promptInjection"]
|
|
self.assertEqual(safe_content(content), content)
|
|
self.assertIsNone(safe_content(fixture["secretLike"]))
|
|
|
|
def test_registered_paths_and_document_uris_cannot_escape(self) -> None:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
source = RegisteredSource(
|
|
source_id=uuid4(),
|
|
project_id=uuid4(),
|
|
project_slug="ludarium",
|
|
source_ref="docs",
|
|
source_type="documentation",
|
|
root=Path(directory),
|
|
)
|
|
with self.assertRaises(PathEscapeError):
|
|
source.canonical_path("../secrets")
|
|
with self.assertRaises(DocumentNotFound):
|
|
InMemoryKnowledgeIndex().get_document(uuid4(), "itworx-docs://ludarium/docs/%2e%2e")
|
|
|
|
def test_ssrf_and_token_substitution_boundaries_are_rejected(self) -> None:
|
|
with self.assertRaises(ValueError):
|
|
GiteaSettings(
|
|
base_url="https://169.254.169.254", token_reference="docker-" + "secret://gitea"
|
|
)
|
|
with self.assertRaises(ValueError):
|
|
GiteaSettings(
|
|
base_url="https://localhost", token_reference="docker-" + "secret://gitea"
|
|
)
|
|
with self.assertRaises(ValueError):
|
|
AliasRecord(
|
|
project_slug="ludarium",
|
|
environment="prod",
|
|
alias="/mcp/ludarium/prod",
|
|
upstream_url="https://127.0.0.1:4444/mcp",
|
|
)
|
|
|
|
def test_gateway_drift_is_reported_and_unknown_gateway_is_not_deleted(self) -> None:
|
|
desired = GatewayDesiredState(slug="knowledge", url="https://knowledge.example/mcp")
|
|
unknown = GatewaySnapshot(
|
|
slug="operator-owned",
|
|
url="https://operator.example/mcp",
|
|
description="outside catalog",
|
|
transport="STREAMABLEHTTP",
|
|
enabled=True,
|
|
status="ready",
|
|
)
|
|
state = CatalogState(
|
|
documents=(),
|
|
gateways=(desired,),
|
|
desired_hash="0" * 64,
|
|
source_files=("catalog/project.yaml",),
|
|
)
|
|
plan = build_plan(state, (unknown,), set())
|
|
self.assertEqual(plan.actions[0].action, "create")
|
|
self.assertEqual(plan.drift[0].kind, "unknown_gateway")
|
|
self.assertNotIn("delete", {action.action for action in plan.actions})
|
|
|
|
def test_json_scan_output_is_serializable_without_credentials(self) -> None:
|
|
payload = json.dumps(scan_repository(), sort_keys=True)
|
|
self.assertNotIn("BEGIN PRIVATE KEY", payload)
|
|
self.assertNotIn("Authorization: Bearer", payload)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|