diff --git a/js/settings.js b/js/settings.js index 4c27b01d..de76fd9f 100644 --- a/js/settings.js +++ b/js/settings.js @@ -135,22 +135,27 @@ var antivirusSettings = antivirusSettings || { function av_mode_show_options(str, mode = 'slow') { - if ( str === 'daemon' || str === 'kaspersky' || str === 'icap'){ + if ( str === 'daemon' || str === 'kaspersky' || str === 'icap' || str === 'symantec'){ $('tr.av_socket, tr.av_path').hide(mode); $('tr.av_host, tr.av_port').show(mode); } else if ( str === 'socket' ) { $('tr.av_socket').show(mode); $('tr.av_path, tr.av_host, tr.av_port').hide(mode); } else if (str === 'executable'){ - $('tr.av_socket, tr.av_host, tr.av_port').hide(mode); + $('tr.av_socket, tr.av_host, tr.av_port, tr.av_password_action').hide(mode); $('tr.av_path').show(mode); } - if (str === 'icap'){ + if (str === 'icap' || str === 'symantec'){ $('tr.av_icap_service, tr.av_icap_header, tr.av_icap_preset, tr.av_icap_mode, tr.av_icap_tls').show(mode); } else { $('tr.av_icap_service, tr.av_icap_header, tr.av_icap_preset, tr.av_icap_mode, tr.av_icap_tls').hide(mode); } - if (str === 'kaspersky' || str === 'icap') { + if (str === 'symantec'){ + $('tr.av_password_action').show(mode); + } else { + $('tr.av_password_action').hide(mode); + } + if (str === 'kaspersky' || str === 'icap' || str === 'symantec') { $('#antivirus-advanced-wrapper').hide(mode); } else { $('#antivirus-advanced-wrapper').show(mode); diff --git a/lib/AppConfig.php b/lib/AppConfig.php index c99e5327..2704d81d 100644 --- a/lib/AppConfig.php +++ b/lib/AppConfig.php @@ -18,6 +18,7 @@ * @method ?string getAvCmdOptions() * @method ?string getAvPath() * @method ?string getAvInfectedAction() + * @method ?string getAvPasswordAction() * @method ?string getAvStreamMaxLength() * @method string getAvIcapMode() * @method ?string getAvIcapRequestService() @@ -34,6 +35,7 @@ * @method null setAvChunkSize(int $chunkSize) * @method null setAvPath(string $avPath) * @method null setAvInfectedAction(string $avInfectedAction) + * @method null setAvPasswordAction(string $avPasswordAction) * @method null setAvIcapScanBackground(string $scanBackground) * @method null setAvIcapMode(string $mode) * @method null setAvIcapRequestService($reqService) @@ -65,6 +67,7 @@ class AppConfig { 'av_icap_chunk_size' => '1048576', 'av_icap_connect_timeout' => '5', 'av_scan_first_bytes' => -1, + 'av_password_action' => 'deny', ]; /** diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index abc04a48..aeaf39e6 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -47,6 +47,7 @@ public function __construct($appName, IRequest $request, AppConfig $appconfig, I * @param string $avCmdOptions - extra command line options * @param string $avPath - path to antivirus executable (Executable mode) * @param string $avInfectedAction - action performed on infected files + * @param string $avPasswordAction - action performed on password protected files * @param $avStreamMaxLength - reopen socket after bytes * @param int $avMaxFileSize - file size limit * @param int $avScanFirstBytes - scan size limit @@ -62,6 +63,7 @@ public function save( $avCmdOptions, $avPath, $avInfectedAction, + $avPasswordAction, $avStreamMaxLength, $avMaxFileSize, $avScanFirstBytes, @@ -77,6 +79,7 @@ public function save( $this->settings->setAvCmdOptions($avCmdOptions); $this->settings->setAvPath($avPath); $this->settings->setAvInfectedAction($avInfectedAction); + $this->settings->setAvPasswordAction($avPasswordAction); $this->settings->setAvStreamMaxLength($avStreamMaxLength); $this->settings->setAvMaxFileSize($avMaxFileSize); $this->settings->setAvScanFirstBytes($avScanFirstBytes); diff --git a/lib/ICAP/SymantecICAP.php b/lib/ICAP/SymantecICAP.php new file mode 100644 index 00000000..7e94fbc9 --- /dev/null +++ b/lib/ICAP/SymantecICAP.php @@ -0,0 +1,164 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Files_Antivirus\Scanner; + +use OCA\Files_Antivirus\AppConfig; +use OCA\Files_Antivirus\ICAP\ICAPClient; +use OCA\Files_Antivirus\ICAP\ICAPRequest; +use OCA\Files_Antivirus\ICAP\ICAPTlsClient; +use OCA\Files_Antivirus\Status; +use OCA\Files_Antivirus\StatusFactory; +use OCP\ICertificateManager; +use Psr\Log\LoggerInterface; + +class SymantecICAP extends ScannerBase { + /** @var ICAPClient::MODE_REQ_MOD|ICAPClient::MODE_RESP_MOD */ + private string $mode; + private ICAPClient $icapClient; + private ?ICAPRequest $icapRequest; + private string $service; + private string $virusHeader; + private int $chunkSize; + private bool $tls; + private string $passwordProtected; + + public function __construct( + AppConfig $config, + LoggerInterface $logger, + StatusFactory $statusFactory, + ICertificateManager $certificateManager + ) { + parent::__construct($config, $logger, $statusFactory); + + $avHost = $this->appConfig->getAvHost(); + $avPort = $this->appConfig->getAvPort(); + $this->service = $config->getAvIcapRequestService(); + $this->virusHeader = $config->getAvIcapResponseHeader(); + $this->chunkSize = (int)$config->getAvChunkSize(); + $this->mode = $config->getAvIcapMode(); + $this->tls = $config->getAvIcapTls(); + $this->passwordProtected = $config->getAvPasswordAction(); + + if (!($avHost && $avPort)) { + throw new \RuntimeException('The ICAP port and host are not set up.'); + } + if ($this->tls) { + $this->icapClient = new ICAPTlsClient($avHost, (int)$avPort, (int)$config->getAvIcapConnectTimeout(), $certificateManager); + } else { + $this->icapClient = new ICAPClient($avHost, (int)$avPort, (int)$config->getAvIcapConnectTimeout()); + } + } + + public function initScanner() { + parent::initScanner(); + $this->writeHandle = fopen("php://temp", 'w+'); + $path = '/' . trim($this->path, '/'); + if (str_contains($path, '.ocTransferId') && str_ends_with($path, '.part')) { + [$path] = explode('.ocTransferId', $path, 2); + } + $remote = $this->request?->getRemoteAddress(); + $encodedPath = implode("/", array_map("rawurlencode", explode("/", $path))); + if ($this->mode === ICAPClient::MODE_REQ_MOD) { + $this->icapRequest = $this->icapClient->reqmod($this->service, [ + 'Allow' => 204, + "X-Client-IP" => $remote, + ], [ + "PUT $encodedPath HTTP/1.0", + "Host: nextcloud" + ]); + } else { + $this->icapRequest = $this->icapClient->respmod($this->service, [ + 'Allow' => 204, + "X-Client-IP" => $remote, + ], [ + "GET $encodedPath HTTP/1.0", + "Host: nextcloud", + ], [ + "HTTP/1.0 200 OK", + "Content-Length: 1", // a dummy, non-zero, content length seems to be enough + ]); + } + } + + protected function writeChunk($chunk) { + if (ftell($this->writeHandle) > $this->chunkSize) { + $this->flushBuffer(); + } + parent::writeChunk($chunk); + } + + private function flushBuffer() { + rewind($this->writeHandle); + $data = stream_get_contents($this->writeHandle); + $this->icapRequest->write($data); + $this->writeHandle = fopen("php://temp", 'w+'); + } + + protected function scanBuffer() { + $this->flushBuffer(); + $response = $this->icapRequest->finish(); + $code = $response->getStatus()->getCode(); + $unchecked_list = array("decode_error","max_archive_layers_exceeded"); + $blocked_list = array("file_type_blocked", "file_extension_blocked"); + + $this->status->setNumericStatus(Status::SCANRESULT_CLEAN); + if ($code === 200 || $code === 204) { + // c-icap/clamav reports this header + $virus = $response->getIcapHeaders()[$this->virusHeader] ?? false; + if ($virus) { + $this->status->setNumericStatus(Status::SCANRESULT_INFECTED); + $this->status->setDetails($virus); + } + + // kaspersky(pre 2020 product editions) and McAfee handling + $respHeader = $response->getResponseHeaders()['HTTP_STATUS'] ?? ''; + if (\strpos($respHeader, '403 Forbidden') || \strpos($respHeader, '403 VirusFound')) { + $this->status->setNumericStatus(Status::SCANRESULT_INFECTED); + } + } elseif ($code === 202) { + $this->status->setNumericStatus(Status::SCANRESULT_UNCHECKED); + } elseif ($code === 500 && $response->getIcapHeaders()['X-Error-Code'] === 'password_protected') { + if ($this->passwordProtected === "accept") { + $this->status->setNumericStatus(Status::SCANRESULT_CLEAN); + } else { + $this->status->setNumericStatus(Status::SCANRESULT_INFECTED); + } + } elseif ($code === 500 && in_array($response->getIcapHeaders()['X-Error-Code'], $unchecked_list)) { + $this->status->setNumericStatus(Status::SCANRESULT_UNCHECKED); + } elseif ($code === 500 && in_array($response->getIcapHeaders()['X-Error-Code'], $blocked_list)) { + $this->status->setNumericStatus(Status::SCANRESULT_INFECTED); + } else { + throw new \RuntimeException('Invalid response from ICAP server'); + } + } + + protected function shutdownScanner() { + $this->scanBuffer(); + } + + public function setDebugCallback(callable $callback): void { + $this->icapClient->setDebugCallback($callback); + } +} \ No newline at end of file diff --git a/lib/Scanner/ScannerFactory.php b/lib/Scanner/ScannerFactory.php index b6ab653d..8b31cd88 100644 --- a/lib/Scanner/ScannerFactory.php +++ b/lib/Scanner/ScannerFactory.php @@ -47,6 +47,9 @@ public function getScanner(string $path) { case 'icap': $scannerClass = ICAP::class; break; + case 'symantec': + $scannerClass = SymantecICAP::class; + break; default: throw new \InvalidArgumentException('Application is misconfigured. Please check the settings at the admin page. Invalid mode: ' . $avMode); } diff --git a/templates/settings.php b/templates/settings.php index 7424d5e3..71ef7ac2 100644 --- a/templates/settings.php +++ b/templates/settings.php @@ -23,6 +23,7 @@ 'socket' => $l->t('ClamAV Daemon (Socket)'), 'kaspersky' => $l->t('Kaspersky Daemon'), 'icap' => $l->t('ICAP server'), + 'symantec' => $l->t('Symantec Icap'), ], $_['avMode'])) ?> @@ -121,6 +122,11 @@ + + + + + diff --git a/test.patch b/test.patch new file mode 100644 index 00000000..7db6ec2c --- /dev/null +++ b/test.patch @@ -0,0 +1,130 @@ +diff --git a/js/settings.js b/js/settings.js +index cdfe82a..e503e9a 100644 +--- a/js/settings.js ++++ b/js/settings.js +@@ -130,22 +130,27 @@ var antivirusSettings = antivirusSettings || { + + + function av_mode_show_options(str, mode = 'slow') { +- if ( str === 'daemon' || str === 'kaspersky' || str === 'icap'){ ++ if ( str === 'daemon' || str === 'kaspersky' || str === 'icap' || str === 'symantec'){ + $('tr.av_socket, tr.av_path').hide(mode); + $('tr.av_host, tr.av_port').show(mode); + } else if ( str === 'socket' ) { + $('tr.av_socket').show(mode); + $('tr.av_path, tr.av_host, tr.av_port').hide(mode); + } else if (str === 'executable'){ +- $('tr.av_socket, tr.av_host, tr.av_port').hide(mode); ++ $('tr.av_socket, tr.av_host, tr.av_port, tr.av_password_action').hide(mode); + $('tr.av_path').show(mode); + } +- if (str === 'icap'){ ++ if (str === 'icap' || str === 'symantec'){ + $('tr.av_icap_service, tr.av_icap_header, tr.av_icap_preset, tr.av_icap_mode, tr.av_icap_tls').show(mode); + } else { + $('tr.av_icap_service, tr.av_icap_header, tr.av_icap_preset, tr.av_icap_mode, tr.av_icap_tls').hide(mode); + } +- if (str === 'kaspersky' || str === 'icap') { ++ if (str === 'symantec'){ ++ $('tr.av_password_action').show(mode); ++ } else { ++ $('tr.av_password_action').hide(mode); ++ } ++ if (str === 'kaspersky' || str === 'icap' || str === 'symantec') { + $('#antivirus-advanced-wrapper').hide(mode); + } else { + $('#antivirus-advanced-wrapper').show(mode); +diff --git a/lib/AppConfig.php b/lib/AppConfig.php +index 8c127c5..6bc12a0 100644 +--- a/lib/AppConfig.php ++++ b/lib/AppConfig.php +@@ -19,6 +19,7 @@ use OCP\IConfig; + * @method ?string getAvCmdOptions() + * @method ?string getAvPath() + * @method ?string getAvInfectedAction() ++ * @method ?string getAvPasswordAction() + * @method ?string getAvStreamMaxLength() + * @method string getAvIcapMode() + * @method ?string getAvIcapRequestService() +@@ -35,6 +36,7 @@ use OCP\IConfig; + * @method null setAvChunkSize(int $chunkSize) + * @method null setAvPath(string $avPath) + * @method null setAvInfectedAction(string $avInfectedAction) ++ * @method null setAvPasswordAction(string $avPasswordAction) + * @method null setAvIcapScanBackground(string $scanBackground) + * @method null setAvIcapMode(string $mode) + * @method null setAvIcapRequestService($reqService) +@@ -66,6 +68,7 @@ class AppConfig { + 'av_icap_chunk_size' => '1048576', + 'av_icap_connect_timeout' => '5', + 'av_scan_first_bytes' => -1, ++ 'av_password_action' => 'deny', + ]; + + /** +diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php +index c44c1e9..1c08e49 100644 +--- a/lib/Controller/SettingsController.php ++++ b/lib/Controller/SettingsController.php +@@ -48,6 +48,7 @@ class SettingsController extends Controller { + * @param string $avCmdOptions - extra command line options + * @param string $avPath - path to antivirus executable (Executable mode) + * @param string $avInfectedAction - action performed on infected files ++ * @param string $avPasswordAction - action performed on password protected files + * @param $avStreamMaxLength - reopen socket after bytes + * @param int $avMaxFileSize - file size limit + * @param int $avScanFirstBytes - scan size limit +@@ -63,6 +64,7 @@ class SettingsController extends Controller { + $avCmdOptions, + $avPath, + $avInfectedAction, ++ $avPasswordAction, + $avStreamMaxLength, + $avMaxFileSize, + $avScanFirstBytes, +@@ -78,6 +80,7 @@ class SettingsController extends Controller { + $this->settings->setAvCmdOptions($avCmdOptions); + $this->settings->setAvPath($avPath); + $this->settings->setAvInfectedAction($avInfectedAction); ++ $this->settings->setAvPasswordAction($avPasswordAction); + $this->settings->setAvStreamMaxLength($avStreamMaxLength); + $this->settings->setAvMaxFileSize($avMaxFileSize); + $this->settings->setAvScanFirstBytes($avScanFirstBytes); +diff --git a/lib/Scanner/ScannerFactory.php b/lib/Scanner/ScannerFactory.php +index f682765..145c1af 100644 +--- a/lib/Scanner/ScannerFactory.php ++++ b/lib/Scanner/ScannerFactory.php +@@ -41,6 +41,9 @@ class ScannerFactory { + case 'icap': + $scannerClass = ICAP::class; + break; ++ case 'symantec': ++ $scannerClass = SymantecICAP::class; ++ break; + default: + throw new \InvalidArgumentException('Application is misconfigured. Please check the settings at the admin page. Invalid mode: ' . $avMode); + } +diff --git a/templates/settings.php b/templates/settings.php +index b55e0bd..9802f41 100644 +--- a/templates/settings.php ++++ b/templates/settings.php +@@ -16,6 +16,7 @@ script('files_antivirus', 'settings'); + 'socket' => $l->t('ClamAV Daemon (Socket)'), + 'kaspersky' => $l->t('Kaspersky Daemon'), + 'icap' => $l->t('ICAP server'), ++ 'symantec' => $l->t('Symantec Icap'), + ], $_['avMode'])) ?> + + +@@ -114,6 +115,11 @@ script('files_antivirus', 'settings'); + + + ++ ++ ++ ++ ++ + + + \ No newline at end of file