Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
56 / 56 |
|
100.00% |
8 / 8 |
CRAP | |
100.00% |
1 / 1 |
| CicapVerdict | |
100.00% |
56 / 56 |
|
100.00% |
8 / 8 |
34 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| interpret | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
11 | |||
| assertContinuation | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| threats | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
9 | |||
| isDiagnostic | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| violations | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
8 | |||
| malformed | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| contradiction | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | /** |
| 4 | * SPDX-License-Identifier: EUPL-1.2 |
| 5 | * |
| 6 | * This file is part of icap-flow. |
| 7 | * |
| 8 | * Licensed under the EUPL, Version 1.2 only (the "Licence"); |
| 9 | * you may not use this work except in compliance with the Licence. |
| 10 | * You may obtain a copy of the Licence at: |
| 11 | * |
| 12 | * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 |
| 13 | * |
| 14 | * Unless required by applicable law or agreed to in writing, software |
| 15 | * distributed under the Licence is distributed on an "AS IS" basis, |
| 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 17 | */ |
| 18 | |
| 19 | declare(strict_types=1); |
| 20 | |
| 21 | namespace Ndrstmr\Icap; |
| 22 | |
| 23 | use Ndrstmr\Icap\DTO\IcapResponse; |
| 24 | use Ndrstmr\Icap\DTO\ScanResult; |
| 25 | use Ndrstmr\Icap\Exception\IcapProtocolException; |
| 26 | |
| 27 | /** |
| 28 | * The verified mode for the c-icap `virus_scan` service with ClamAV (c-icap 0.6.3, c-icap-modules 0.5.7, ClamAV 1.4.3; |
| 29 | * docs/cicap-clamav.md): it reads an answer of that service as the service means it, and an answer that does not show |
| 30 | * that the file was scanned is an error, never a clean result. Pass it to {@see IcapClient} to turn the mode on. |
| 31 | * |
| 32 | * It is made for one service, given as the path and query a scan names it by (`/avscan`): the client scans with no other |
| 33 | * service while the mode is on, and takes the endpoint from the {@see Config}. The service has to be set up as the |
| 34 | * reference service is: the answers cannot show whether it scans every type of file. |
| 35 | * |
| 36 | * - Infection: `X-Infection-Found: Type=n; Resolution=n; Threat=name;` and `X-Violations-Found: count - name |
| 37 | * problem-id resolution-id ...` (folded over several lines), read by value and count, whatever the spelling of the |
| 38 | * names and whatever the header list of the {@see Config}. The result names the threat, not the text of the header. |
| 39 | * A 200 or 206 with an infection is a finding, also as the answer to a preview. Headers that contradict each other |
| 40 | * or a 204, or that are malformed, are an error, and so is a 100 Continue that carries any of that. |
| 41 | * - Not scanned: ClamAV reports a file over its limits (`AlertExceedsMax`) and an encrypted archive or document |
| 42 | * (`AlertEncrypted`) with a name of its own, `Heuristics.Limits.Exceeded...` and `Heuristics.Encrypted...`. That says |
| 43 | * that the file was not looked into: an error, not a finding. Every other name is a finding. |
| 44 | * - Clean: only a 204 to a file that was sent whole (the preview held all of it, or the service asked for the rest with |
| 45 | * 100 Continue). For a type it does not scan the service answers the preview of a file that is not all sent with a |
| 46 | * 204 at once, so that is an error, and so is a 200 or 206 without an infection (the message sent back, a block or |
| 47 | * error page). |
| 48 | * |
| 49 | * What an answer cannot show is whether the service scans everything it is sent: a service that skips a type also |
| 50 | * answers a file that the preview holds, and sometimes a file sent without a preview, with the same 204 as for a clean |
| 51 | * scan. That is for the configuration of the service. |
| 52 | */ |
| 53 | final readonly class CicapVerdict |
| 54 | { |
| 55 | /** |
| 56 | * @param string $service The service the mode is for: an absolute path with an optional query, as a scan names it |
| 57 | * |
| 58 | * @throws \InvalidArgumentException When the service is not that |
| 59 | */ |
| 60 | public function __construct(public string $service) |
| 61 | { |
| 62 | IcapUri::assertService($service); |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * The result of the final answer to a message that was sent whole, or of an answer that ends a preview that holds |
| 67 | * only the start of the file. |
| 68 | * |
| 69 | * @param bool $complete whether the service had the whole file when it answered |
| 70 | * |
| 71 | * @throws IcapProtocolException When the answer does not show that the file was scanned, or contradicts itself |
| 72 | */ |
| 73 | public function interpret(IcapResponse $response, bool $complete): ScanResult |
| 74 | { |
| 75 | $status = $response->statusCode; |
| 76 | if ($status !== 200 && $status !== 204 && $status !== 206) { |
| 77 | throw new IcapProtocolException('Unexpected ICAP status: ' . $status, $status); |
| 78 | } |
| 79 | |
| 80 | $threats = $this->threats($response); |
| 81 | if ($status === 204) { |
| 82 | if ($threats !== []) { |
| 83 | throw self::contradiction($status); |
| 84 | } |
| 85 | if (!$complete) { |
| 86 | throw new IcapProtocolException('The service ended the exchange with a clean answer before it had the whole file: it did not scan the file.', $status); |
| 87 | } |
| 88 | |
| 89 | return new ScanResult(false, null, $response); |
| 90 | } |
| 91 | |
| 92 | if ($threats !== []) { |
| 93 | $findings = array_values(array_filter($threats, static fn (string $threat): bool => !self::isDiagnostic($threat))); |
| 94 | if ($findings === []) { |
| 95 | throw new IcapProtocolException('The service reports that it did not look into the whole file (a limit of the engine, or an encrypted file): it did not scan the file.', $status); |
| 96 | } |
| 97 | |
| 98 | return new ScanResult(true, $findings[0], $response); |
| 99 | } |
| 100 | |
| 101 | $page = $response->encapsulatedStatus; |
| 102 | throw new IcapProtocolException( |
| 103 | $page !== null && $page >= 400 |
| 104 | ? sprintf('The service answered with an error or block page (HTTP %d) and no infection signal.', $page) |
| 105 | : 'The service sent the message back without an infection signal although it may answer 204: it did not scan the file.', |
| 106 | $status, |
| 107 | ); |
| 108 | } |
| 109 | |
| 110 | /** |
| 111 | * A 100 Continue asks for the rest of the file and decides nothing. One that reports an infection, or carries an |
| 112 | * infection header that is malformed, is none: the file is not sent on. |
| 113 | * |
| 114 | * @throws IcapProtocolException When the 100 contradicts itself |
| 115 | */ |
| 116 | public function assertContinuation(IcapResponse $response): void |
| 117 | { |
| 118 | if ($this->threats($response) !== []) { |
| 119 | throw self::contradiction($response->statusCode); |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * The threats the headers report, or none. |
| 125 | * |
| 126 | * @return list<string> |
| 127 | * |
| 128 | * @throws IcapProtocolException When a header is malformed or the headers contradict each other |
| 129 | */ |
| 130 | private function threats(IcapResponse $response): array |
| 131 | { |
| 132 | $status = $response->statusCode; |
| 133 | |
| 134 | $named = []; |
| 135 | foreach (HeaderFields::values($response->headers, 'X-Infection-Found') as $value) { |
| 136 | if (preg_match('/\AType=[0-9]+; *Resolution=[0-9]+; *Threat=([^\s;].*?);?\z/', $value, $m) !== 1) { |
| 137 | throw self::malformed($status); |
| 138 | } |
| 139 | $named[] = $m[1]; |
| 140 | } |
| 141 | |
| 142 | $listed = []; |
| 143 | $counted = HeaderFields::values($response->headers, 'X-Violations-Found'); |
| 144 | foreach ($counted as $value) { |
| 145 | $listed = [...$listed, ...self::violations($value, $status)]; |
| 146 | } |
| 147 | |
| 148 | // A count of none says that there is nothing to report, and the list is the threats of the other header. |
| 149 | $none = $counted !== [] && $listed === []; |
| 150 | $other = $listed !== [] && array_diff($named, $listed) !== []; |
| 151 | if ($named !== [] && ($none || $other)) { |
| 152 | throw self::contradiction($status); |
| 153 | } |
| 154 | |
| 155 | return array_values(array_unique([...$named, ...$listed])); |
| 156 | } |
| 157 | |
| 158 | /** |
| 159 | * The names ClamAV 1.4.3 gives to a limit it ran into and to an encrypted file it could not look into |
| 160 | * (`AlertExceedsMax`, `AlertEncrypted` of clamd.conf), and nothing else. |
| 161 | */ |
| 162 | private static function isDiagnostic(string $threat): bool |
| 163 | { |
| 164 | return preg_match('/\AHeuristics\.(?:Limits\.Exceeded|Encrypted)(?:\.|\z)/', $threat) === 1; |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * The names in `count - name problem-id resolution-id ...` (the lines of the header, folded into one), exactly as |
| 169 | * many as the count says. |
| 170 | * |
| 171 | * @return list<string> |
| 172 | * |
| 173 | * @throws IcapProtocolException When the value is not that structure |
| 174 | */ |
| 175 | private static function violations(string $value, int $status): array |
| 176 | { |
| 177 | $tokens = preg_split('/\s+/', $value, -1, PREG_SPLIT_NO_EMPTY); |
| 178 | if ($tokens === false || $tokens === [] || preg_match('/\A[0-9]{1,6}\z/', $tokens[0]) !== 1) { |
| 179 | throw self::malformed($status); |
| 180 | } |
| 181 | $count = (int) $tokens[0]; |
| 182 | if (count($tokens) !== 1 + 4 * $count) { |
| 183 | throw self::malformed($status); |
| 184 | } |
| 185 | |
| 186 | $names = []; |
| 187 | for ($i = 0; $i < $count; $i++) { |
| 188 | [, $name, $problem, $resolution] = array_slice($tokens, 1 + 4 * $i, 4); |
| 189 | if (!ctype_digit($problem) || !ctype_digit($resolution)) { |
| 190 | throw self::malformed($status); |
| 191 | } |
| 192 | $names[] = $name; |
| 193 | } |
| 194 | |
| 195 | return $names; |
| 196 | } |
| 197 | |
| 198 | private static function malformed(int $status): IcapProtocolException |
| 199 | { |
| 200 | return new IcapProtocolException('An infection header of the response is malformed.', $status); |
| 201 | } |
| 202 | |
| 203 | private static function contradiction(int $status): IcapProtocolException |
| 204 | { |
| 205 | return new IcapProtocolException('The infection headers of the response contradict each other or its status.', $status); |
| 206 | } |
| 207 | } |