test_security.py python
130 lines 5.4 KB
Raw
sha256:91e875d4a97bb1e35f37992f803988d5713931f1782d870c371c50054574af22 Add fixture provenance retention deletion Human minor ⚠ breaking 42 days ago
1 """Security tier tests for Scooling Lab request and dependency boundaries."""
2
3 from __future__ import annotations
4
5 import unittest
6
7 from scooling_lab_helpers import PROJECT_ROOT, valid_payload
8
9 from scooling_lab.bom import audit_repository_paths
10 from scooling_lab.contracts import TrainingJobRequest
11 from scooling_lab.fake_worker import fixture_dataset_bytes
12 from scooling_lab.errors import ApiError
13 from scooling_lab.license_policy import BomEntry, LicensePolicyError, validate_entry
14 from scooling_lab.service import TrainingApiService
15 from scooling_lab.store import TrainingJobStore
16
17
18 class ScoolingLabSecurityTests(unittest.TestCase):
19 """Security tests for injection rejection and AGPL boundary enforcement."""
20
21 def test_security_rejects_path_traversal_command_and_url_injection(self) -> None:
22 """Untrusted paths, commands, URLs, callbacks, and worker fields fail closed."""
23
24 attacks: list[dict[str, object]] = []
25 path_payload = valid_payload("path")
26 path_payload["datasetId"] = "../private"
27 attacks.append(path_payload)
28
29 command_payload = valid_payload("command")
30 command_payload["trainingParameters"] = {"epochs": 1, "command": "rm -rf /"}
31 attacks.append(command_payload)
32
33 callback_payload = valid_payload("callback")
34 callback_payload["callbackUrl"] = "https://attacker.invalid/callback"
35 attacks.append(callback_payload)
36
37 worker_payload = valid_payload("worker")
38 worker_payload["workerUrl"] = "http://127.0.0.1:9999"
39 attacks.append(worker_payload)
40
41 for payload in attacks:
42 with self.subTest(payload=payload):
43 with self.assertRaises(ApiError):
44 TrainingJobRequest.from_mapping(payload)
45
46 def test_security_secret_scan_and_bom_audit_are_wired(self) -> None:
47 """CI contains gitleaks and the repository path audit passes locally."""
48
49 workflow = (PROJECT_ROOT / ".github/workflows/ci.yml").read_text(
50 encoding="utf-8"
51 )
52 self.assertIn("gitleaks detect", workflow)
53 self.assertIn("python -m scooling_lab.bom", workflow)
54 audit_repository_paths(PROJECT_ROOT)
55
56 def test_security_agpl_package_and_blocked_paths_are_rejected(self) -> None:
57 """AGPL license ids and Studio/CLI paths cannot enter the BOM."""
58
59 with self.assertRaises(LicensePolicyError):
60 validate_entry(
61 BomEntry(
62 name="studio",
63 version="1.0.0",
64 license="AGPL-3.0-only",
65 source_path="studio/backend/run.py",
66 evidence="fixture",
67 )
68 )
69
70 def test_security_provenance_excludes_synthetic_fixture_text_markers(self) -> None:
71 """Provenance output never contains text from the fixture dataset."""
72
73 marker = "synthetic learner practices"
74 self.assertIn(marker, fixture_dataset_bytes().decode("utf-8"))
75 service = TrainingApiService(TrainingJobStore())
76 created = service.create_training_job(valid_payload("marker-absence"))
77 provenance_text = str(service.get_provenance(str(created["id"])))
78
79 self.assertNotIn(marker, provenance_text)
80 self.assertNotIn("study habits", provenance_text)
81 self.assertNotIn("astronomy facts", provenance_text)
82
83 def test_security_forged_artifact_id_cannot_delete_another_job(self) -> None:
84 """A valid artifact id from one job cannot delete a different job."""
85
86 service = TrainingApiService(TrainingJobStore())
87 first = service.create_training_job(valid_payload("forged-a"))
88 second = service.create_training_job(valid_payload("forged-b"))
89 first_job_id = str(first["id"])
90 second_job_id = str(second["id"])
91 second_artifact_id = str(service.list_artifacts(second_job_id)["artifacts"][0]["id"])
92
93 with self.assertRaises(ApiError):
94 service.delete_artifact(first_job_id, second_artifact_id)
95
96 self.assertEqual(service.get_training_job(first_job_id)["status"], "succeeded")
97 self.assertEqual(service.get_training_job(second_job_id)["status"], "succeeded")
98
99 def test_security_path_traversal_and_injection_ids_are_rejected(self) -> None:
100 """Job and artifact ids reject path traversal and command characters."""
101
102 service = TrainingApiService(TrainingJobStore())
103 created = service.create_training_job(valid_payload("id-injection"))
104 job_id = str(created["id"])
105 artifact_id = str(service.list_artifacts(job_id)["artifacts"][0]["id"])
106
107 attacks = (
108 ("../private", artifact_id),
109 (job_id, "../artifact"),
110 (job_id, f"{artifact_id};rm-rf"),
111 ("https://attacker.invalid/job", artifact_id),
112 )
113 for attack_job_id, attack_artifact_id in attacks:
114 with self.subTest(job_id=attack_job_id, artifact_id=attack_artifact_id):
115 with self.assertRaises(ApiError):
116 service.delete_artifact(attack_job_id, attack_artifact_id)
117 with self.assertRaises(LicensePolicyError):
118 validate_entry(
119 BomEntry(
120 name="unsloth-cli",
121 version="1.0.0",
122 license="Apache-2.0",
123 source_path="vendor/unsloth_cli/app.py",
124 evidence="fixture",
125 )
126 )
127
128
129 if __name__ == "__main__":
130 unittest.main()
File History 1 commit
sha256:91e875d4a97bb1e35f37992f803988d5713931f1782d870c371c50054574af22 Add fixture provenance retention deletion Human minor 42 days ago