Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
56 / 56
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
CicapVerdict
100.00% covered (success)
100.00%
56 / 56
100.00% covered (success)
100.00%
8 / 8
34
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 interpret
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
11
 assertContinuation
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 threats
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 isDiagnostic
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 violations
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 malformed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 contradiction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
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
19declare(strict_types=1);
20
21namespace Ndrstmr\Icap;
22
23use Ndrstmr\Icap\DTO\IcapResponse;
24use Ndrstmr\Icap\DTO\ScanResult;
25use 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 */
53final 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}