Batch remote drift checks into agent file manifests
This commit is contained in:
@@ -57,6 +57,209 @@ class Response(io.BytesIO):
|
||||
self.headers = headers or {}
|
||||
|
||||
|
||||
class ManifestTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name) / "app"
|
||||
self.root.mkdir()
|
||||
self.stopping = threading.Event()
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def write(self, path, content=b"content"):
|
||||
target = self.root / path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
return target
|
||||
|
||||
def manifest(self, roots=None, ignore_names=None, paths=None):
|
||||
return module.managed_manifest(self.root, {
|
||||
"roots": ["scripts", "configs", "plugins"] if roots is None else roots,
|
||||
"ignoreNames": [] if ignore_names is None else ignore_names,
|
||||
"paths": [] if paths is None else paths,
|
||||
}, self.stopping)
|
||||
|
||||
def test_hashes_sorted_unique_files_across_overlapping_roots_and_skips_ignored_names(self):
|
||||
self.write("scripts/nested/z.gsc", b"script")
|
||||
self.write("configs/server.cfg", b"config")
|
||||
self.write("scripts/cache/ignored.gsc")
|
||||
self.write("scripts/nested/ignore.gsc")
|
||||
expected = [{"path": path, "size": len(content), "sha256": module.digest(content)} for path, content in (
|
||||
("configs/server.cfg", b"config"), ("scripts/nested/z.gsc", b"script"),
|
||||
)]
|
||||
self.assertEqual(self.manifest(["scripts/nested", "configs", "scripts", "scripts", "plugins"], ["cache", "ignore.gsc"]), {"files": expected})
|
||||
|
||||
def test_nested_symlinks_are_skipped_but_requested_symlink_roots_fail(self):
|
||||
self.write("scripts/good.gsc")
|
||||
outside = Path(self.temp.name) / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret").write_bytes(b"secret")
|
||||
(self.root / "scripts/file-link").symlink_to(outside / "secret")
|
||||
(self.root / "scripts/dir-link").symlink_to(outside, target_is_directory=True)
|
||||
(self.root / "scripts/dangling").symlink_to(outside / "missing")
|
||||
self.assertEqual([item["path"] for item in self.manifest()["files"]], ["scripts/good.gsc"])
|
||||
for roots in (["scripts/dir-link"], ["scripts", "scripts/dir-link"]):
|
||||
with self.subTest(roots=roots), self.assertRaisesRegex(AgentError, "symlink|Symlink"):
|
||||
self.manifest(roots)
|
||||
|
||||
def test_validates_roots_and_ignored_names(self):
|
||||
for roots in ([], "scripts", [""], ["."], [".."], ["../scripts"], ["/scripts"], ["scripts//nested"], ["scripts/../configs"], [None], ["scripts"] * 65):
|
||||
with self.subTest(roots=roots), self.assertRaises(AgentError):
|
||||
self.manifest(roots)
|
||||
for ignored in ("cache", [None], [""], ["."], [".."], ["scripts/cache"], ["a\\b"], ["a\x00b"], ["cache"] * 257):
|
||||
with self.subTest(ignored=ignored), self.assertRaises(AgentError):
|
||||
self.manifest(ignore_names=ignored)
|
||||
for paths in ("scripts/a", [None], ["../outside"], ["."], ["scripts/a"] * 10001):
|
||||
with self.subTest(paths=paths), self.assertRaises(AgentError):
|
||||
self.manifest(paths=paths)
|
||||
|
||||
def test_explicit_managed_paths_cannot_be_symlinks_or_directories(self):
|
||||
self.write("scripts/good.gsc")
|
||||
(self.root / "scripts/link.gsc").symlink_to(self.root / "scripts/good.gsc")
|
||||
(self.root / "scripts/dangling.gsc").symlink_to(self.root / "missing")
|
||||
for path in ("scripts", "scripts/link.gsc", "scripts/dangling.gsc"):
|
||||
with self.subTest(path=path), self.assertRaises(AgentError):
|
||||
self.manifest(paths=[path])
|
||||
self.assertEqual([item["path"] for item in self.manifest(paths=["scripts/good.gsc", "scripts/missing.gsc", "missing/future.gsc"])["files"]], ["scripts/good.gsc"])
|
||||
|
||||
def test_explicit_managed_paths_are_validated_again_after_the_scan(self):
|
||||
self.write("scripts/good.gsc")
|
||||
original = module.managed_open
|
||||
opened = 0
|
||||
|
||||
@module.contextlib.contextmanager
|
||||
def swap(root, value):
|
||||
nonlocal opened
|
||||
opened += 1
|
||||
if opened == 2:
|
||||
(self.root / "scripts/good.gsc").unlink()
|
||||
(self.root / "scripts/good.gsc").symlink_to(self.root / "missing")
|
||||
with original(root, value) as stream:
|
||||
yield stream
|
||||
|
||||
with patch.object(module, "managed_open", side_effect=swap), self.assertRaisesRegex(AgentError, "Symlinks"):
|
||||
self.manifest(paths=["scripts/good.gsc"])
|
||||
|
||||
def test_missing_destinations_are_empty_but_unavailable_app_and_regular_roots_fail(self):
|
||||
self.assertEqual(self.manifest(), {"files": []})
|
||||
self.write("scripts", b"not a directory")
|
||||
with self.assertRaisesRegex(AgentError, "complete managed tree"):
|
||||
self.manifest()
|
||||
(self.root / "scripts").unlink()
|
||||
self.root.rmdir()
|
||||
with self.assertRaisesRegex(AgentError, "complete managed tree"):
|
||||
self.manifest()
|
||||
replacement = Path(self.temp.name) / "replacement"
|
||||
replacement.mkdir()
|
||||
self.root.symlink_to(replacement, target_is_directory=True)
|
||||
with self.assertRaisesRegex(AgentError, "symlink"):
|
||||
self.manifest()
|
||||
|
||||
def test_errors_and_special_files_never_return_a_partial_manifest(self):
|
||||
self.write("scripts/good.gsc")
|
||||
self.write("scripts/unreadable.gsc")
|
||||
original = module.os.stat
|
||||
|
||||
def denied(path, *args, **kwargs):
|
||||
if path == "unreadable.gsc":
|
||||
raise PermissionError("unreadable")
|
||||
return original(path, *args, **kwargs)
|
||||
|
||||
with patch.object(module.os, "stat", side_effect=denied), self.assertRaisesRegex(AgentError, "complete managed tree"):
|
||||
self.manifest()
|
||||
module.os.mkfifo(self.root / "scripts/fifo")
|
||||
with self.assertRaisesRegex(AgentError, "non-regular"):
|
||||
self.manifest()
|
||||
|
||||
def test_scan_limits_fail_instead_of_truncating_results(self):
|
||||
self.write("scripts/a.gsc")
|
||||
self.write("scripts/b.gsc")
|
||||
for setting, limit, message in (
|
||||
("MANIFEST_MAX_ENTRIES", 1, "entry limit"),
|
||||
("MANIFEST_MAX_BYTES", 1, "result size limit"),
|
||||
("MANIFEST_MAX_SECONDS", 0, "time limit"),
|
||||
("MANIFEST_MAX_DEPTH", 0, "depth limit"),
|
||||
):
|
||||
with self.subTest(setting=setting), patch.object(module, setting, limit), self.assertRaisesRegex(AgentError, message):
|
||||
self.manifest()
|
||||
self.stopping.set()
|
||||
with self.assertRaisesRegex(AgentError, "interrupted"):
|
||||
self.manifest()
|
||||
|
||||
def test_files_are_streamed_and_not_limited_by_the_content_read_api(self):
|
||||
content = b"x" * (2 * 1024 * 1024 + 13)
|
||||
self.write("plugins/large.dll", content)
|
||||
original = module.os.fdopen
|
||||
reads = []
|
||||
|
||||
@module.contextlib.contextmanager
|
||||
def tracked(*args, **kwargs):
|
||||
with original(*args, **kwargs) as stream:
|
||||
proxy = MagicMock(wraps=stream)
|
||||
def read(size):
|
||||
reads.append(size)
|
||||
return stream.read(size)
|
||||
proxy.read.side_effect = read
|
||||
yield proxy
|
||||
|
||||
with patch.object(module, "MAX_FILE", 1), patch.object(module.os, "fdopen", side_effect=tracked):
|
||||
result = self.manifest()
|
||||
self.assertEqual(result, {"files": [{"path": "plugins/large.dll", "size": len(content), "sha256": module.digest(content)}]})
|
||||
self.assertGreater(len(reads), 2)
|
||||
self.assertEqual(set(reads), {1024 * 1024})
|
||||
|
||||
def test_file_changes_while_hashing_invalidate_the_scan(self):
|
||||
target = self.write("scripts/changing.gsc", b"before")
|
||||
original = module.hashlib.sha256
|
||||
|
||||
def changing_hash():
|
||||
digest = original()
|
||||
proxy = MagicMock(wraps=digest)
|
||||
def update(block):
|
||||
digest.update(block)
|
||||
prior = target.stat()
|
||||
target.write_bytes(b"after!")
|
||||
module.os.utime(target, ns=(prior.st_atime_ns, prior.st_mtime_ns + 1))
|
||||
proxy.update.side_effect = update
|
||||
return proxy
|
||||
|
||||
with patch.object(module.hashlib, "sha256", side_effect=changing_hash), self.assertRaisesRegex(AgentError, "file changed"):
|
||||
self.manifest()
|
||||
|
||||
def test_symlink_swap_after_validation_cannot_hash_outside_the_app(self):
|
||||
self.write("scripts/good.gsc")
|
||||
outside = Path(self.temp.name) / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret").write_bytes(b"secret")
|
||||
original = module.scoped
|
||||
|
||||
def swap(root, relative, **kwargs):
|
||||
result = original(root, relative, **kwargs)
|
||||
(self.root / "scripts").rename(self.root / "saved")
|
||||
(self.root / "scripts").symlink_to(outside, target_is_directory=True)
|
||||
return result
|
||||
|
||||
with patch.object(module, "scoped", side_effect=swap), self.assertRaisesRegex(AgentError, "complete managed tree"):
|
||||
self.manifest(["scripts"])
|
||||
|
||||
def test_disappearing_app_after_hashing_is_not_successful_evidence(self):
|
||||
self.write("scripts/good.gsc")
|
||||
original = module.hashlib.sha256
|
||||
|
||||
def moving_hash():
|
||||
digest = original()
|
||||
proxy = MagicMock(wraps=digest)
|
||||
def update(block):
|
||||
digest.update(block)
|
||||
self.root.rename(self.root.with_name("moved-app"))
|
||||
proxy.update.side_effect = update
|
||||
return proxy
|
||||
|
||||
with patch.object(module.hashlib, "sha256", side_effect=moving_hash), self.assertRaisesRegex(AgentError, "complete managed tree"):
|
||||
self.manifest()
|
||||
|
||||
|
||||
class AgentTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
@@ -227,6 +430,20 @@ class AgentTests(unittest.TestCase):
|
||||
self.assertEqual(second["result"], first["result"])
|
||||
self.assertEqual(sum(call[:2] == ("container", "restart") for call in self.docker.calls), 1)
|
||||
|
||||
def test_manifest_is_advertised_and_reconciles_as_a_read_operation(self):
|
||||
self.create()
|
||||
operation = self.operation("files.manifest", {"roots": ["configs", "scripts"], "ignoreNames": [], "paths": ["configs/server.cfg"]})
|
||||
self.assertIn("files.manifest", self.agent.inventory()["capabilities"])
|
||||
self.assertTrue(self.agent.read_operation(operation))
|
||||
self.assertFalse(self.agent.health_operation(operation))
|
||||
self.agent.journal.accept(operation)
|
||||
self.agent.journal.update(operation["id"], "unknown")
|
||||
calls = list(self.docker.calls)
|
||||
result = self.agent.execute(operation)
|
||||
self.assertEqual(result["state"], "completed")
|
||||
self.assertEqual(json.loads(result["result"]), {"files": [{"path": "configs/server.cfg", "size": 8, "sha256": module.digest(b"original")}]})
|
||||
self.assertEqual(self.docker.calls, calls)
|
||||
|
||||
def test_reusing_id_with_different_payload_is_rejected(self):
|
||||
self.agent.execute(self.operation("host.inspect"))
|
||||
with self.assertRaisesRegex(AgentError, "different content"):
|
||||
|
||||
Reference in New Issue
Block a user