From cbe95fa49a6d68b02c4e2400c771799b2870a787 Mon Sep 17 00:00:00 2001 From: Mikhail Bushkov Date: Thu, 27 Feb 2025 11:22:46 +0100 Subject: [PATCH] Final touches before the 3490 release. * Minor fixes in the client builder logic. * Version bumped to 3490, CHANGELOG updated. * Further RRG development. --- CHANGELOG.md | 8 +++++ compose.yaml | 2 +- .../client_build.py | 29 +++++++------------ .../api/signed_commands.proto | 2 +- .../rrg/action/execute_signed_command.proto | 8 ++--- .../grr_response_proto/signed_commands.proto | 2 +- .../grr_response_server/bin/command_signer.py | 6 ++-- .../bin/command_signer_test.py | 2 +- .../grr_response_server/command_signer.py | 4 +-- .../command_signer_test_mixin.py | 6 ++-- .../grr_response_server/databases/db.py | 2 +- .../databases/db_signed_commands_test.py | 14 ++++----- .../flows/general/cloud_test.py | 8 ++--- .../flows/general/hardware_test.py | 8 ++--- .../gui/api_plugins/signed_commands.py | 4 +-- .../gui/api_plugins/signed_commands_test.py | 16 +++++----- .../models/signed_commands_test.py | 2 +- .../private_key_file_command_signer.py | 4 +-- version.ini | 2 +- 19 files changed, 65 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f408640a..99885a855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +### Removed + +### Changed + +## [3.4.9.0] - 2025-02-27 + +### Added + * Added support for listing `%SystemDrive%\Users` as a supplementary mechanism for collecting user profiles on Windows (additionally to using data from the registry). diff --git a/compose.yaml b/compose.yaml index aeb665dc8..80959cc9e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -28,7 +28,7 @@ services: - ./docker_config_files/mysql/init.sh:/docker-entrypoint-initdb.d/init.sh - db_data:/var/lib/mysql:rw ports: - - "3306:3306" + - "3306:3306" expose: - "3306" networks: diff --git a/grr/client_builder/grr_response_client_builder/client_build.py b/grr/client_builder/grr_response_client_builder/client_build.py index 323e866cc..9e0599149 100644 --- a/grr/client_builder/grr_response_client_builder/client_build.py +++ b/grr/client_builder/grr_response_client_builder/client_build.py @@ -213,6 +213,17 @@ def BuildTemplate(self, context=None, output=None): # The repacker uses this context to chose the .msi extension for the # repacked installer. context.append("Target:WindowsMsi") + if "Target:Darwin" in context: + if not grr_config.CONFIG.Get( + "ClientBuilder.install_dir", context=context + ): + raise ValueError("ClientBuilder.install_dir must be set on Darwin.") + if not grr_config.CONFIG.Get( + "ClientBuilder.fleetspeak_plist_path", context=context + ): + raise ValueError( + "ClientBuilder.fleetspeak_plist_path must be set on Darwin." + ) template_path = None # If output is specified, place the built template file there, otherwise @@ -422,24 +433,6 @@ def main(args): logger.handlers = [handler] if args.subparser_name == "build": - if grr_config.CONFIG.ContextApplied("Platform:Darwin"): - # We know that the client builder is run on Darwin, so we can check that - # the required config options are set. But the builder config options use - # the "Target:Darwin" context, as they care about the target system that - # the template is built for, not the system that the builder is run on. - # The fact that we build macOS templates on Darwin is technically - # an implementation detail even though it is impossible to build macOS - # templates on any other platform. - if not grr_config.CONFIG.Get( - "ClientBuilder.install_dir", - context=[contexts.TARGET_DARWIN], - ): - raise RuntimeError("ClientBuilder.install_dir must be set.") - if not grr_config.CONFIG.Get( - "ClientBuilder.fleetspeak_plist_path", - context=[contexts.TARGET_DARWIN], - ): - raise RuntimeError("ClientBuilder.fleetspeak_plist_path must be set.") TemplateBuilder().BuildTemplate(context=context, output=args.output) elif args.subparser_name == "repack": if args.debug_build: diff --git a/grr/proto/grr_response_proto/api/signed_commands.proto b/grr/proto/grr_response_proto/api/signed_commands.proto index 2945209fe..46a254a34 100644 --- a/grr/proto/grr_response_proto/api/signed_commands.proto +++ b/grr/proto/grr_response_proto/api/signed_commands.proto @@ -26,7 +26,7 @@ message ApiCommand { // Whether the command should allow execution with arbitrary // standard input without it being pre-signed. - bool unsigned_stdin = 7; + bool unsigned_stdin_allowed = 7; } } diff --git a/grr/proto/grr_response_proto/rrg/action/execute_signed_command.proto b/grr/proto/grr_response_proto/rrg/action/execute_signed_command.proto index 50b9ae414..f8004bd18 100644 --- a/grr/proto/grr_response_proto/rrg/action/execute_signed_command.proto +++ b/grr/proto/grr_response_proto/rrg/action/execute_signed_command.proto @@ -10,7 +10,7 @@ package rrg.action.execute_signed_command; import "google/protobuf/duration.proto"; import "grr_response_proto/rrg/fs.proto"; -message SignedCommand { +message Command { // Path to the executable file to execute. rrg.fs.Path path = 1; @@ -29,18 +29,18 @@ message SignedCommand { // Whether the command should allow execution with arbitrary // standard input without it being pre-signed. - bool unsigned_stdin = 5; + bool unsigned_stdin_allowed = 5; } } message Args { - // Serialized `SignedCommand` message to execute. + // Serialized `Command` message to execute. bytes command = 1; // Standard input to pass to the executed command. // // For this option to work, the command that has been signed has to allow - // arbitrary standard input by having the `unsigned_stdin` flag set. + // arbitrary standard input by having the `unsigned_stdin_allowed` flag set. bytes unsigned_stdin = 2; // An [Ed25519][1] signature of the command. diff --git a/grr/proto/grr_response_proto/signed_commands.proto b/grr/proto/grr_response_proto/signed_commands.proto index f19a6d14f..2dfa83129 100644 --- a/grr/proto/grr_response_proto/signed_commands.proto +++ b/grr/proto/grr_response_proto/signed_commands.proto @@ -16,7 +16,7 @@ message Command { repeated EnvVar env_vars = 3; oneof stdin { // Whether the stdin of the command is unsigned. - bool unsigned_stdin = 4; + bool unsigned_stdin_allowed = 4; // The stdin of the command, if it is signed. bytes signed_stdin = 5; } diff --git a/grr/server/grr_response_server/bin/command_signer.py b/grr/server/grr_response_server/bin/command_signer.py index 100f23e05..b54046c28 100644 --- a/grr/server/grr_response_server/bin/command_signer.py +++ b/grr/server/grr_response_server/bin/command_signer.py @@ -41,9 +41,9 @@ def _GetCommandSigner() -> command_signer.AbstractCommandSigner: def _ConvertToRrgCommand( command: api_signed_commands_pb2.ApiCommand, -) -> execute_signed_command_pb2.SignedCommand: +) -> execute_signed_command_pb2.Command: """Converts a GRR command to a RRG command.""" - rrg_command = execute_signed_command_pb2.SignedCommand() + rrg_command = execute_signed_command_pb2.Command() rrg_command.path.raw_bytes = command.path.encode("utf-8") rrg_command.args.extend(command.args) @@ -52,7 +52,7 @@ def _ConvertToRrgCommand( if command.HasField("signed_stdin"): rrg_command.signed_stdin = command.signed_stdin else: - rrg_command.unsigned_stdin = command.unsigned_stdin + rrg_command.unsigned_stdin_allowed = command.unsigned_stdin_allowed return rrg_command diff --git a/grr/server/grr_response_server/bin/command_signer_test.py b/grr/server/grr_response_server/bin/command_signer_test.py index 46ebe078d..1c8018353 100644 --- a/grr/server/grr_response_server/bin/command_signer_test.py +++ b/grr/server/grr_response_server/bin/command_signer_test.py @@ -18,7 +18,7 @@ def testConvertToRrgCommand(self): rrg_command = command_signer._ConvertToRrgCommand(command) - expected = execute_signed_command_pb2.SignedCommand() + expected = execute_signed_command_pb2.Command() expected.path.raw_bytes = b"foo" expected.args.extend(["bar", "baz"]) expected.env["FOO"] = "bar" diff --git a/grr/server/grr_response_server/command_signer.py b/grr/server/grr_response_server/command_signer.py index 5683d4739..321fed678 100644 --- a/grr/server/grr_response_server/command_signer.py +++ b/grr/server/grr_response_server/command_signer.py @@ -14,14 +14,14 @@ class AbstractCommandSigner(metaclass=abc.ABCMeta): """A base class for command signers.""" @abc.abstractmethod - def Sign(self, command: execute_signed_command_pb2.SignedCommand) -> bytes: + def Sign(self, command: execute_signed_command_pb2.Command) -> bytes: """Signs a command and returns the signature.""" @abc.abstractmethod def Verify( self, signature: bytes, - command: execute_signed_command_pb2.SignedCommand, + command: execute_signed_command_pb2.Command, ) -> None: """Validates a signature for given data with a verification key. diff --git a/grr/server/grr_response_server/command_signer_test_mixin.py b/grr/server/grr_response_server/command_signer_test_mixin.py index 07c264be5..d923048de 100644 --- a/grr/server/grr_response_server/command_signer_test_mixin.py +++ b/grr/server/grr_response_server/command_signer_test_mixin.py @@ -11,11 +11,11 @@ class CommandSignerTestMixin: signer: command_signer.AbstractCommandSigner def testVerifySignatureCanSignAndVerify(self): # pylint: disable=invalid-name - command = execute_signed_command_pb2.SignedCommand() + command = execute_signed_command_pb2.Command() command.path.raw_bytes = b"/bin/ls" command.args.append("-l") command.env["PATH"] = "/usr/bin" - command.unsigned_stdin = True + command.unsigned_stdin_allowed = True signature = self.signer.Sign(command) self.assertLen(signature, 64) @@ -23,7 +23,7 @@ def testVerifySignatureCanSignAndVerify(self): # pylint: disable=invalid-name self.signer.Verify(signature, command) def testVerifySignatureRaisesWhenSignatureIsInvalid(self): # pylint: disable=invalid-name - command = execute_signed_command_pb2.SignedCommand() + command = execute_signed_command_pb2.Command() command.path.raw_bytes = b"/bin/ls" signature = b"invalid signature" diff --git a/grr/server/grr_response_server/databases/db.py b/grr/server/grr_response_server/databases/db.py index a7c9ace43..79b325f08 100644 --- a/grr/server/grr_response_server/databases/db.py +++ b/grr/server/grr_response_server/databases/db.py @@ -4989,7 +4989,7 @@ def WriteSignedCommands( signed_commands: Sequence[signed_commands_pb2.SignedCommand], ) -> None: for signed_command in signed_commands: - command = rrg_execute_signed_command_pb2.SignedCommand() + command = rrg_execute_signed_command_pb2.Command() command.ParseFromString(signed_command.command) _ValidateSignedCommandId(signed_command.id) diff --git a/grr/server/grr_response_server/databases/db_signed_commands_test.py b/grr/server/grr_response_server/databases/db_signed_commands_test.py index 618b5ea00..999f83e9a 100644 --- a/grr/server/grr_response_server/databases/db_signed_commands_test.py +++ b/grr/server/grr_response_server/databases/db_signed_commands_test.py @@ -13,7 +13,7 @@ def create_signed_command( path: str = "test_path", signature: bytes = None, args: Optional[list[str]] = None, - unsigned_stdin: bool = False, + unsigned_stdin_allowed: bool = False, signed_stdin: Optional[bytes] = None, env_vars: Optional[list[signed_commands_pb2.Command.EnvVar]] = None, ) -> signed_commands_pb2.SignedCommand: @@ -22,7 +22,7 @@ def create_signed_command( signed_command.id = id_ signed_command.operating_system = operating_system - command = rrg_execute_signed_command_pb2.SignedCommand() + command = rrg_execute_signed_command_pb2.Command() command.path.raw_bytes = path.encode("utf-8") if not signature: @@ -35,7 +35,7 @@ def create_signed_command( for env_var in env_vars: command.env[env_var.name] = env_var.value - command.unsigned_stdin = unsigned_stdin + command.unsigned_stdin_allowed = unsigned_stdin_allowed if signed_stdin: command.signed_stdin = signed_stdin @@ -52,13 +52,13 @@ def testWriteReadSignedCommands_allFields(self): signed_command.operating_system = signed_commands_pb2.SignedCommand.OS.MACOS signed_command.ed25519_signature = b"test_signature" + 50 * b"-" # 64 bytes - command = rrg_execute_signed_command_pb2.SignedCommand() + command = rrg_execute_signed_command_pb2.Command() command.path.raw_bytes = "test_path".encode("utf-8") command.args.extend(["args1", "args2"]) command.env["env_var_1"] = "env_var_1_value" command.env["env_var_2"] = "env_var_2_value" command.signed_stdin = b"signed_stdin" - command.unsigned_stdin = False + command.unsigned_stdin_allowed = False signed_command.command = command.SerializeToString() @@ -104,7 +104,7 @@ def testWriteReadSignedCommands_testPositionalArgsKeepOrder(self): read_command = self.db.ReadSignedCommand( "command", signed_commands_pb2.SignedCommand.OS.LINUX ) - command = rrg_execute_signed_command_pb2.SignedCommand() + command = rrg_execute_signed_command_pb2.Command() command.ParseFromString(read_command.command) self.assertEqual(command.args, ["arg1", "arg2", "arg3"]) @@ -122,7 +122,7 @@ def testWriteReadSignedCommands_testEnvVars(self): read_command = self.db.ReadSignedCommand( "command", signed_commands_pb2.SignedCommand.OS.LINUX ) - command = rrg_execute_signed_command_pb2.SignedCommand() + command = rrg_execute_signed_command_pb2.Command() command.ParseFromString(read_command.command) self.assertEqual( command.env, diff --git a/grr/server/grr_response_server/flows/general/cloud_test.py b/grr/server/grr_response_server/flows/general/cloud_test.py index f4ff44f30..dc55f6b75 100644 --- a/grr/server/grr_response_server/flows/general/cloud_test.py +++ b/grr/server/grr_response_server/flows/general/cloud_test.py @@ -41,7 +41,7 @@ def testRRGGoogleLinux( ) -> None: # TODO: Load signed commands from the `.textproto` file to # ensure integrity. - command = rrg_execute_signed_command_pb2.SignedCommand() + command = rrg_execute_signed_command_pb2.Command() command.path.raw_bytes = "/usr/sbin/dmidecode".encode("utf-8") command.args.append("--string") command.args.append("bios-version") @@ -63,7 +63,7 @@ def ExecuteSignedCommandHandler(session: rrg_test_lib.Session) -> None: args = rrg_execute_signed_command_pb2.Args() assert session.args.Unpack(args) - command = rrg_execute_signed_command_pb2.SignedCommand() + command = rrg_execute_signed_command_pb2.Command() command.ParseFromString(args.command) if command.path.raw_bytes != "/usr/sbin/dmidecode".encode("utf-8"): @@ -220,7 +220,7 @@ def testRRGAmazonLinux( ) -> None: # TODO: Load signed commands from the `.textproto` file to # ensure integrity. - command = rrg_execute_signed_command_pb2.SignedCommand() + command = rrg_execute_signed_command_pb2.Command() command.path.raw_bytes = "/usr/sbin/dmidecode".encode("utf-8") command.args.append("--string") command.args.append("bios-version") @@ -242,7 +242,7 @@ def ExecuteSignedCommandHandler(session: rrg_test_lib.Session) -> None: args = rrg_execute_signed_command_pb2.Args() assert session.args.Unpack(args) - command = rrg_execute_signed_command_pb2.SignedCommand() + command = rrg_execute_signed_command_pb2.Command() command.ParseFromString(args.command) if command.path.raw_bytes != "/usr/sbin/dmidecode".encode("utf-8"): diff --git a/grr/server/grr_response_server/flows/general/hardware_test.py b/grr/server/grr_response_server/flows/general/hardware_test.py index d102f0998..7a50541a5 100644 --- a/grr/server/grr_response_server/flows/general/hardware_test.py +++ b/grr/server/grr_response_server/flows/general/hardware_test.py @@ -36,7 +36,7 @@ def setUpClass(cls): def testRRGLinux(self, db: abstract_db.Database) -> None: # TODO: Load signed commands from the `.textproto` file to # ensure integrity. - command = rrg_execute_signed_command_pb2.SignedCommand() + command = rrg_execute_signed_command_pb2.Command() command.path.raw_bytes = "/usr/sbin/dmidecode".encode("utf-8") command.args.append("-q") signed_command = signed_commands_pb2.SignedCommand() @@ -57,7 +57,7 @@ def ExecuteSignedCommandHandler(session: rrg_test_lib.Session) -> None: args = rrg_execute_signed_command_pb2.Args() assert session.args.Unpack(args) - command = rrg_execute_signed_command_pb2.SignedCommand() + command = rrg_execute_signed_command_pb2.Command() command.ParseFromString(args.command) if command.path.raw_bytes != "/usr/sbin/dmidecode".encode("utf-8"): @@ -227,7 +227,7 @@ def testLinux(self): def testRRGMacos(self, db: abstract_db.Database) -> None: # TODO: Load signed commands from the `.textproto` file to # ensure integrity. - command = rrg_execute_signed_command_pb2.SignedCommand() + command = rrg_execute_signed_command_pb2.Command() command.path.raw_bytes = "/usr/sbin/system_profiler".encode("utf-8") command.args.append("-xml") command.args.append("SPHardwareDataType") @@ -249,7 +249,7 @@ def ExecuteSignedCommandHandler(session: rrg_test_lib.Session) -> None: args = rrg_execute_signed_command_pb2.Args() assert session.args.Unpack(args) - command = rrg_execute_signed_command_pb2.SignedCommand() + command = rrg_execute_signed_command_pb2.Command() command.ParseFromString(args.command) if command.path.raw_bytes != "/usr/sbin/system_profiler".encode("utf-8"): diff --git a/grr/server/grr_response_server/gui/api_plugins/signed_commands.py b/grr/server/grr_response_server/gui/api_plugins/signed_commands.py index 4583e3925..773b78df6 100644 --- a/grr/server/grr_response_server/gui/api_plugins/signed_commands.py +++ b/grr/server/grr_response_server/gui/api_plugins/signed_commands.py @@ -70,12 +70,12 @@ def Handle( # TODO: Add signature verification. raise ValueError("Command signature is required.") - rrg_command = rrg_execute_signed_command_pb2.SignedCommand() + rrg_command = rrg_execute_signed_command_pb2.Command() rrg_command.ParseFromString(args_signed_command.command) if not rrg_command.path.raw_bytes: raise ValueError("Command path is required.") if not rrg_command.HasField( - "unsigned_stdin" + "unsigned_stdin_allowed" ) and not rrg_command.HasField("signed_stdin"): raise ValueError("Command stdin is required.") diff --git a/grr/server/grr_response_server/gui/api_plugins/signed_commands_test.py b/grr/server/grr_response_server/gui/api_plugins/signed_commands_test.py index db67fa64f..151279736 100644 --- a/grr/server/grr_response_server/gui/api_plugins/signed_commands_test.py +++ b/grr/server/grr_response_server/gui/api_plugins/signed_commands_test.py @@ -21,7 +21,7 @@ def create_signed_command( signed_command.id = command_id signed_command.operating_system = operating_system signed_command.ed25519_signature = os.urandom(64) - command = rrg_execute_signed_command_pb2.SignedCommand() + command = rrg_execute_signed_command_pb2.Command() command.path.raw_bytes = "/foo/bar".encode("utf-8") command.signed_stdin = b"stdin" signed_command.command = command.SerializeToString() @@ -47,7 +47,7 @@ def testCreateSignedCommands_AllFieldsGetWrittenToDatabase(self): api_signed_commands_pb2.ApiSignedCommand.OS.WINDOWS ) signed_command.ed25519_signature = b"test-signature" + 50 * b"-" # 64 bytes - command = rrg_execute_signed_command_pb2.SignedCommand() + command = rrg_execute_signed_command_pb2.Command() command.path.raw_bytes = "/foo/bar".encode("utf-8") command.args.extend(["--foo", "--bar"]) command.signed_stdin = b"stdin" @@ -88,7 +88,7 @@ def testCreateSignedCommands_MissingOperatingSystemRaises(self): def testCreateSignedCommands_MissingPathRaises(self): missing_path = create_signed_command("missing_path") - rrg_command = rrg_execute_signed_command_pb2.SignedCommand() + rrg_command = rrg_execute_signed_command_pb2.Command() rrg_command.ParseFromString(missing_path.command) rrg_command.ClearField("path") missing_path.command = rrg_command.SerializeToString() @@ -99,10 +99,10 @@ def testCreateSignedCommands_MissingPathRaises(self): def testCreateSignedCommands_MissingStdinRaises(self): missing_stdin = create_signed_command("missing_stdin") - rrg_command = rrg_execute_signed_command_pb2.SignedCommand() + rrg_command = rrg_execute_signed_command_pb2.Command() rrg_command.ParseFromString(missing_stdin.command) rrg_command.ClearField("signed_stdin") - rrg_command.ClearField("unsigned_stdin") + rrg_command.ClearField("unsigned_stdin_allowed") missing_stdin.command = rrg_command.SerializeToString() args = api_signed_commands_pb2.ApiCreateSignedCommandsArgs() @@ -143,7 +143,7 @@ def setUp(self): self.handler = api_signed_commands.ApiListSignedCommandsHandler() def testApiListSignedCommands(self): - rrg_command = rrg_execute_signed_command_pb2.SignedCommand() + rrg_command = rrg_execute_signed_command_pb2.Command() signed_command_1 = create_signed_command("test_name_1") rrg_command.ParseFromString(signed_command_1.command) @@ -162,10 +162,10 @@ def testApiListSignedCommands(self): self.assertLen(listed_commands.signed_commands, 2) commands_by_id = {c.id: c for c in listed_commands.signed_commands} - command_1 = rrg_execute_signed_command_pb2.SignedCommand() + command_1 = rrg_execute_signed_command_pb2.Command() command_1.ParseFromString(commands_by_id["test_name_1"].command) self.assertEqual(command_1.path.raw_bytes.decode("utf-8"), "/foo/bar") - command_2 = rrg_execute_signed_command_pb2.SignedCommand() + command_2 = rrg_execute_signed_command_pb2.Command() command_2.ParseFromString(commands_by_id["test_name_2"].command) self.assertEqual(command_2.path.raw_bytes.decode("utf-8"), "/foo/bar/baz") diff --git a/grr/server/grr_response_server/models/signed_commands_test.py b/grr/server/grr_response_server/models/signed_commands_test.py index 01ec0d4ab..58838430e 100644 --- a/grr/server/grr_response_server/models/signed_commands_test.py +++ b/grr/server/grr_response_server/models/signed_commands_test.py @@ -14,7 +14,7 @@ def testBaseFields(self): signed_command.id = "foo-id" signed_command.operating_system = signed_commands_pb2.SignedCommand.OS.MACOS signed_command.ed25519_signature = b"foo-signature" - rrg_command = rrg_execute_signed_command_pb2.SignedCommand() + rrg_command = rrg_execute_signed_command_pb2.Command() rrg_command.path.raw_bytes = "foo-path".encode("utf-8") rrg_command.signed_stdin = b"foo-signed-stdin" rrg_command.args.append("foo-arg-1") diff --git a/grr/server/grr_response_server/private_key_file_command_signer.py b/grr/server/grr_response_server/private_key_file_command_signer.py index 0a9881de0..3fbaf123e 100644 --- a/grr/server/grr_response_server/private_key_file_command_signer.py +++ b/grr/server/grr_response_server/private_key_file_command_signer.py @@ -22,11 +22,11 @@ def __init__(self): self._private_key = ed25519.Ed25519PrivateKey.from_private_bytes(key) self._public_key = self._private_key.public_key() - def Sign(self, command: execute_signed_command_pb2.SignedCommand) -> bytes: + def Sign(self, command: execute_signed_command_pb2.Command) -> bytes: return self._private_key.sign(command.SerializeToString()) def Verify( - self, signature: bytes, command: execute_signed_command_pb2.SignedCommand + self, signature: bytes, command: execute_signed_command_pb2.Command ) -> None: try: self._public_key.verify(signature, command.SerializeToString()) diff --git a/version.ini b/version.ini index 3bd209c5c..44493bbe7 100644 --- a/version.ini +++ b/version.ini @@ -2,7 +2,7 @@ major = 3 minor = 4 -revision = 8 +revision = 9 release = 0 local =