packages/sql-catalog/tests/Unit/Reporter/Html/Page/FindingPageTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Reporter\Html\Page;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use SqlCatalog\Core\Catalog\AnalysisProblem;
11use SqlCatalog\Core\Catalog\CallSite;
12use SqlCatalog\Core\Catalog\Catalog;
13use SqlCatalog\Core\Catalog\CatalogEntry;
14use SqlCatalog\Core\Catalog\Finding;
15use SqlCatalog\Core\Catalog\FindingRule;
16use SqlCatalog\Core\Catalog\Placeholder;
17use SqlCatalog\Core\Catalog\Resolution;
18use SqlCatalog\Core\Catalog\Severity;
19use SqlCatalog\Core\Catalog\StatementPart;
20use SqlCatalog\Core\Catalog\ValueDomain;
21use SqlCatalog\Core\Sql\StatementKind;
22use SqlCatalog\Core\Text\LiteralText;
23use SqlCatalog\Core\Text\Origin;
24use SqlCatalog\Core\Text\TextHole;
25use SqlCatalog\Core\Text\TextPattern;
26use SqlCatalog\Core\Type\TypeShape;
27use SqlCatalog\Reporter\Html\CatalogIndex;
28use SqlCatalog\Reporter\Html\CatalogStatistics;
29use SqlCatalog\Reporter\Html\HtmlText;
30use SqlCatalog\Reporter\Html\Page\FindingPage;
31use SqlCatalog\Reporter\Html\Palette;
32use SqlCatalog\Reporter\Html\ReportSite;
33use SqlCatalog\Reporter\Html\Scope;
34use SqlCatalog\Reporter\Html\SqlFormatter;
35use SqlCatalog\Reporter\Html\SqlHighlighter;
36use SqlCatalog\Reporter\Html\StatementList;
37use SqlCatalog\Reporter\Html\StatementRow;
38use SqlCatalog\Reporter\Html\TableName;
39
40#[CoversClass(FindingPage::class)]
41#[UsesClass(CallSite::class)]
42#[UsesClass(Catalog::class)]
43#[UsesClass(CatalogEntry::class)]
44#[UsesClass(CatalogIndex::class)]
45#[UsesClass(CatalogStatistics::class)]
46#[UsesClass(Finding::class)]
47#[UsesClass(FindingRule::class)]
48#[UsesClass(HtmlText::class)]
49#[UsesClass(Palette::class)]
50#[UsesClass(ReportSite::class)]
51#[UsesClass(Resolution::class)]
52#[UsesClass(Scope::class)]
53#[UsesClass(Severity::class)]
54#[UsesClass(SqlHighlighter::class)]
55#[UsesClass(StatementKind::class)]
56#[UsesClass(StatementList::class)]
57#[UsesClass(StatementPart::class)]
58#[UsesClass(StatementRow::class)]
59#[UsesClass(LiteralText::class)]
60#[UsesClass(TextPattern::class)]
61#[UsesClass(AnalysisProblem::class)]
62#[UsesClass(Placeholder::class)]
63#[UsesClass(ValueDomain::class)]
64#[UsesClass(SqlFormatter::class)]
65#[UsesClass(TableName::class)]
66#[UsesClass(Origin::class)]
67#[UsesClass(TextHole::class)]
68#[UsesClass(TypeShape::class)]
69final class FindingPageTest extends TestCase
70{
71    public function testRenderOpensWithWhereToLookAndThenListsEveryRule(): void
72    {
73        $catalog = new Catalog([
74            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'f', 'pdo.query'), [
75                Finding::of(FindingRule::ExternalInput, 'spliced'),
76                Finding::of(FindingRule::AnalysisIncomplete, 'stopped'),
77            ]),
78        ]);
79        $page = (new FindingPage())->render(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()));
80
81        self::assertStringContainsString('<h1>Findings<span class="count">2 findings</span></h1>', $page);
82        self::assertStringContainsString('<h2 id="hotspots">Where to look first</h2>', $page);
83        self::assertStringContainsString('<section class="group" id="rule-external-input">', $page);
84        self::assertStringContainsString('<section class="group" id="rule-analysis-incomplete">', $page);
85        self::assertStringContainsString('Nothing was reported', (new FindingPage())->render(new ReportSite(new Catalog(), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter())));
86    }
87
88    public function testHotspotsLinkEachFunctionAndAreSilentWhenNothingIsFlagged(): void
89    {
90        $catalog = new Catalog([
91            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('src/a.php', 1, 'App\\R::find', 'pdo.query'), [
92                Finding::of(FindingRule::DynamicSql, 'spliced'),
93            ]),
94        ]);
95
96        self::assertStringContainsString(
97            '<tr><td><a class="mono" href="classes/app-r.html#fn-app-r-find">R::find</a></td><td><a class="muted" href="files/src-a-php.html">src/a.php</a></td><td'
98                . ' class="num"><span class="none">0</span></td><td class="num">1</td></tr>',
99            (new FindingPage())->hotspots(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter())),
100        );
101        self::assertSame('', (new FindingPage())->hotspots(new ReportSite(new Catalog(), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter())));
102    }
103
104    public function testSectionHeadsTheRuleWithItsSeverityAndMeaning(): void
105    {
106        $section = (new FindingPage())->section(new ReportSite(new Catalog(), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), FindingRule::DynamicSql, []);
107
108        self::assertStringStartsWith(
109            '<section class="group" id="rule-dynamic-sql"><h2><code>dynamic-sql</code><span class="chip tone-warn">medium</span><span class="count">0 statements</span>',
110            $section,
111        );
112        self::assertStringContainsString('A value is spliced into the statement text instead of being bound.', $section);
113    }
114
115    public function testAnchorsNameTheHotspotsAndEveryRule(): void
116    {
117        $catalog = new Catalog([
118            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'f', 'pdo.query'), [
119                Finding::of(FindingRule::DynamicSql, 'spliced'),
120            ]),
121        ]);
122
123        self::assertSame([['Where to look first', 'hotspots'], ['dynamic-sql', 'rule-dynamic-sql']], (new FindingPage())->anchors(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter())));
124    }
125
126    public function testRenderIsWrittenExactly(): void
127    {
128        $entries = [
129            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromSegments([
130                new LiteralText('SELECT id FROM posts WHERE slug = '),
131                new TextHole(Origin::External, TypeShape::unknown(), '$_GET["s"]'),
132            ]), ['posts'], [new Placeholder('?', 0, null, new ValueDomain('int', [7], true, []))], new CallSite('src/a.php', 4, 'App\\R::find', 'pdo.query'), [
133                Finding::of(FindingRule::ExternalInput, 'spliced'),
134            ], false, ['App\\R::run', 'App\\R::find']),
135            new CatalogEntry('a2', StatementKind::Insert, TextPattern::fromText('INSERT INTO posts (id) VALUES (1)'), ['posts'], [], new CallSite('src/a.php', 9, 'App\\R::add', 'pdo.query'), []),
136            new CatalogEntry('a3', StatementKind::Update, TextPattern::fromText('UPDATE posts SET title = ? WHERE id = ?'), ['posts'], [], new CallSite('src/a.php', 14, 'App\\R::add', 'pdo.prepare'), [
137                Finding::of(FindingRule::PlaceholderCountMismatch, 'one bound'),
138            ]),
139            new CatalogEntry('b1', StatementKind::Delete, TextPattern::fromText('DELETE FROM users WHERE id = 1'), ['users'], [], new CallSite('src/b.php', 3, 'App\\Admin\\U::drop', 'pdo.query'), []),
140            new CatalogEntry('b2', StatementKind::Alter, TextPattern::fromText('ALTER TABLE users ADD x INT'), ['users'], [], new CallSite('src/b.php', 8, 'App\\Admin\\U::migrate', 'pdo.exec'), []),
141            new CatalogEntry('c1', StatementKind::Select, TextPattern::fromHole(new TextHole(Origin::Budget, TypeShape::unknown())), [], [], new CallSite('lib/c.php', 2, 'helper', 'mysqli.query'), [
142                Finding::of(FindingRule::AnalysisIncomplete, 'stopped'),
143            ], true, [], true),
144            new CatalogEntry('c2', StatementKind::Unknown, TextPattern::fromHole(new TextHole(Origin::Unreached, TypeShape::unknown(), '$db->query($sql)')), [], [], new CallSite('lib/c.php', 6, '{main}', 'unmatched'), [
145                Finding::of(FindingRule::CallNotAnalyzed, 'unseen'),
146            ]),
147            new CatalogEntry('d1', StatementKind::Select, TextPattern::fromText('SELECT * FROM posts p JOIN users u ON u.id = p.author'), ['posts', 'users'], [], new CallSite('src/d.php', 1, 'App\\R::find', 'pdo.query'), [
148                Finding::of(FindingRule::DynamicSql, 'dyn'),
149            ]),
150            new CatalogEntry('e1', StatementKind::Show, TextPattern::fromText('SHOW TABLES'), [], [], new CallSite('src/e.php', 1, 'App\\R::find', 'pdo.query'), []),
151            new CatalogEntry('f1', StatementKind::Select, TextPattern::fromText('SELECT 1 FROM t1'), ['t1'], [], new CallSite('src/f.php', 1, 'App\\F::a', 'pdo.query'), []),
152            new CatalogEntry('f2', StatementKind::Select, TextPattern::fromText('SELECT 2 FROM t2'), ['t2'], [], new CallSite('src/f.php', 2, 'App\\F::b', 'pdo.query'), []),
153            new CatalogEntry('f3', StatementKind::Select, TextPattern::fromText('SELECT 3 FROM t3'), ['t3'], [], new CallSite('src/f.php', 3, 'App\\G::a', 'pdo.query'), []),
154            new CatalogEntry('f4', StatementKind::Select, TextPattern::fromText('SELECT 4 FROM t4'), ['t4'], [], new CallSite('src/f.php', 4, 'App\\H::a', 'pdo.query'), []),
155            new CatalogEntry('f5', StatementKind::Select, TextPattern::fromText('SELECT 5 FROM t5'), ['t5'], [], new CallSite('src/g.php', 1, 'App\\I::a', 'pdo.query'), []),
156            new CatalogEntry('f6', StatementKind::Select, TextPattern::fromText('SELECT 6 FROM t6'), ['t6'], [], new CallSite('src/h.php', 1, 'App\\J::a', 'pdo.query'), []),
157            new CatalogEntry('f7', StatementKind::Select, TextPattern::fromText('SELECT 7 FROM t7'), ['t7'], [], new CallSite('src/i.php', 1, 'App\\K::a', 'pdo.query'), [
158                Finding::of(FindingRule::DynamicSql, 'dyn'),
159            ]),
160            new CatalogEntry('f8', StatementKind::Select, TextPattern::fromText('SELECT 8 FROM t8'), ['t8'], [], new CallSite('src/j.php', 1, 'App\\L::a', 'pdo.query'), [
161                Finding::of(FindingRule::DynamicSql, 'dyn'),
162            ]),
163            new CatalogEntry('f9', StatementKind::Select, TextPattern::fromText('SELECT 9 FROM t9'), ['t9'], [], new CallSite('src/k.php', 1, 'App\\M::a', 'pdo.query'), [
164                Finding::of(FindingRule::DynamicSql, 'dyn'),
165            ]),
166        ];
167        $catalog = new Catalog($entries, [new AnalysisProblem('src/broken.php', 'broken')]);
168        $site = new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
169
170        self::assertSame(
171            '<h1>Findings<span class="count">8 findings</span></h1><p class="lede">A finding is a judgement about a statement the analyzer read: a value '
172                . 'spliced into the text, a value that comes from outside the program, or a search that stopped short. What stopped the analysis is reported to'
173                . 'o, so a gap in the catalog is never silent.</p><h2 id="hotspots">Where to look first</h2><p class="lede">The functions issuing statements wi'
174                . 'th a high or medium finding: SQL built from external input, or from values spliced into the text rather than bound. A function high on this '
175                . 'list is one to read before trusting its queries.</p><div class="table-wrap"><table class="sortable" data-dd-sortable><thead><tr><th scope="c'
176                . 'ol" data-dd-sort="text">Function</th><th scope="col" data-dd-sort="text">File</th><th scope="col" class="num" data-dd-sort="number">High</th'
177                . '><th scope="col" class="num" data-dd-sort="number">Medium</th></tr></thead><tbody><tr><td><a class="mono" href="classes/app-r.html#fn-app-r-'
178                . 'find">R::find</a></td><td><a class="muted" href="files/src-a-php.html">src/a.php</a></td><td class="num">1</td><td class="num">1</td></tr><t'
179                . 'r><td><a class="mono" href="classes/app-k.html#fn-app-k-a">K::a</a></td><td><a class="muted" href="files/src-i-php.html">src/i.php</a></td><'
180                . 'td class="num"><span class="none">0</span></td><td class="num">1</td></tr><tr><td><a class="mono" href="classes/app-l.html#fn-app-l-a">L::a<'
181                . '/a></td><td><a class="muted" href="files/src-j-php.html">src/j.php</a></td><td class="num"><span class="none">0</span></td><td class="num">1'
182                . '</td></tr><tr><td><a class="mono" href="classes/app-m.html#fn-app-m-a">M::a</a></td><td><a class="muted" href="files/src-k-php.html">src/k.p'
183                . 'hp</a></td><td class="num"><span class="none">0</span></td><td class="num">1</td></tr><tr><td><a class="mono" href="classes/app-r.html#fn-ap'
184                . 'p-r-add">R::add</a></td><td><a class="muted" href="files/src-a-php.html">src/a.php</a></td><td class="num"><span class="none">0</span></td><'
185                . 'td class="num">1</td></tr></tbody></table></div><section class="group" id="rule-dynamic-sql"><h2><code>dynamic-sql</code><span class="chip t'
186                . 'one-warn">medium</span><span class="count">4 statements</span><a class="anchor" href="#rule-dynamic-sql">#</a></h2><p class="lede">A value i'
187                . 's spliced into the statement text instead of being bound.</p><ol class="rows"><li class="row" data-kind="select" data-resolution="resolved" '
188                . 'data-severity="medium" data-rule="dynamic-sql" data-sink="pdo.query" data-open="" data-table="posts users" data-namespace="App" data-class="'
189                . 'App\\R" data-function="App\\R::find" data-file="src/d.php"><a class="row-main" href="statements/d1.html"><span class="chip tone-blue">SELECT</'
190                . 'span><pre class="code"><span class="tok-kw">SELECT</span>
191    *
192<span class="tok-kw">FROM</span>
193    posts p
194<span class="tok-kw">JOIN</span'
195                . '>
196    users u
197<span class="tok-kw">ON</span>
198    u.id = p.author</pre></a><p class="row-meta"><a href="files/src-d-php.html">src/d.php:1</a>'
199                . '<a href="classes/app-r.html#fn-app-r-find">R::find</a><a class="chip chip-ghost" href="tables/posts.html">posts</a><a class="chip chip-ghost'
200                . '" href="tables/users.html">users</a><span class="chip tone-warn" title="The most serious finding on this statement">medium</span></p></li><l'
201                . 'i class="row" data-kind="select" data-resolution="resolved" data-severity="medium" data-rule="dynamic-sql" data-sink="pdo.query" data-open="'
202                . '" data-table="t7" data-namespace="App" data-class="App\\K" data-function="App\\K::a" data-file="src/i.php"><a class="row-main" href="statement'
203                . 's/f7.html"><span class="chip tone-blue">SELECT</span><pre class="code"><span class="tok-kw">SELECT</span>
204    <span class="tok-num">7</span>'
205                . '
206<span class="tok-kw">FROM</span>
207    t7</pre></a><p class="row-meta"><a href="files/src-i-php.html">src/i.php:1</a><a href="classes/app-k.h'
208                . 'tml#fn-app-k-a">K::a</a><a class="chip chip-ghost" href="tables/t7.html">t7</a><span class="chip tone-warn" title="The most serious finding '
209                . 'on this statement">medium</span></p></li><li class="row" data-kind="select" data-resolution="resolved" data-severity="medium" data-rule="dyn'
210                . 'amic-sql" data-sink="pdo.query" data-open="" data-table="t8" data-namespace="App" data-class="App\\L" data-function="App\\L::a" data-file="src'
211                . '/j.php"><a class="row-main" href="statements/f8.html"><span class="chip tone-blue">SELECT</span><pre class="code"><span class="tok-kw">SELEC'
212                . 'T</span>
213    <span class="tok-num">8</span>
214<span class="tok-kw">FROM</span>
215    t8</pre></a><p class="row-meta"><a href="files/src-j-php.ht'
216                . 'ml">src/j.php:1</a><a href="classes/app-l.html#fn-app-l-a">L::a</a><a class="chip chip-ghost" href="tables/t8.html">t8</a><span class="chip '
217                . 'tone-warn" title="The most serious finding on this statement">medium</span></p></li><li class="row" data-kind="select" data-resolution="reso'
218                . 'lved" data-severity="medium" data-rule="dynamic-sql" data-sink="pdo.query" data-open="" data-table="t9" data-namespace="App" data-class="App'
219                . '\\M" data-function="App\\M::a" data-file="src/k.php"><a class="row-main" href="statements/f9.html"><span class="chip tone-blue">SELECT</span><'
220                . 'pre class="code"><span class="tok-kw">SELECT</span>
221    <span class="tok-num">9</span>
222<span class="tok-kw">FROM</span>
223    t9</pre></a><p c'
224                . 'lass="row-meta"><a href="files/src-k-php.html">src/k.php:1</a><a href="classes/app-m.html#fn-app-m-a">M::a</a><a class="chip chip-ghost" hre'
225                . 'f="tables/t9.html">t9</a><span class="chip tone-warn" title="The most serious finding on this statement">medium</span></p></li></ol></sectio'
226                . 'n><section class="group" id="rule-analysis-incomplete"><h2><code>analysis-incomplete</code><span class="chip tone-neutral">low</span><span c'
227                . 'lass="count">1 statement</span><a class="anchor" href="#rule-analysis-incomplete">#</a></h2><p class="lede">A cycle or an analysis budget st'
228                . 'opped the search before it closed.</p><ol class="rows"><li class="row" data-kind="select" data-resolution="incomplete" data-severity="low" d'
229                . 'ata-rule="analysis-incomplete" data-sink="mysqli.query" data-open="open" data-table="" data-namespace="" data-class="" data-function="helper'
230                . '" data-file="lib/c.php"><a class="row-main" href="statements/c1.html"><span class="chip tone-blue">SELECT</span><pre class="code"><span clas'
231                . 's="hole tone-warn" title="This is a gap: a dependency the analyzer stopped following fills it.">{$}</span></pre></a><p class="row-meta"><a h'
232                . 'ref="files/lib-c-php.html">lib/c.php:2</a><a href="files/lib-c-php.html#fn-helper">helper</a><span class="chip chip-ghost" title="A cycle or'
233                . ' an analysis budget stopped the search before it closed.">incomplete</span></p></li></ol></section><section class="group" id="rule-call-not-'
234                . 'analyzed"><h2><code>call-not-analyzed</code><span class="chip tone-neutral">low</span><span class="count">1 statement</span><a class="anchor'
235                . '" href="#rule-call-not-analyzed">#</a></h2><p class="lede">A call that carries a statement was found but never examined.</p><ol class="rows"'
236                . '><li class="row" data-kind="unknown" data-resolution="not-analyzed" data-severity="low" data-rule="call-not-analyzed" data-sink="unmatched" '
237                . 'data-open="open" data-table="" data-namespace="" data-class="" data-function="{main}" data-file="lib/c.php"><a class="row-main" href="statem'
238                . 'ents/c2.html"><span class="chip tone-slate">UNKNOWN</span><pre class="code"><span class="tok-com">no statement was read from this call</span'
239                . '> $db-&gt;query($sql)</pre></a><p class="row-meta"><a href="files/lib-c-php.html">lib/c.php:6</a><span class="chip tone-neutral" title="The '
240                . 'call was found but never examined, so nothing was read from it.">not-analyzed</span></p></li></ol></section><section class="group" id="rule-'
241                . 'external-input"><h2><code>external-input</code><span class="chip tone-danger">high</span><span class="count">1 statement</span><a class="anc'
242                . 'hor" href="#rule-external-input">#</a></h2><p class="lede">A value spliced into the statement text comes from external input.</p><ol class="'
243                . 'rows"><li class="row" data-kind="select" data-resolution="external-input" data-severity="high" data-rule="external-input" data-sink="pdo.que'
244                . 'ry" data-open="" data-table="posts" data-namespace="App" data-class="App\\R" data-function="App\\R::find" data-file="src/a.php"><a class="row-'
245                . 'main" href="statements/a1.html"><span class="chip tone-blue">SELECT</span><pre class="code"><span class="tok-kw">SELECT</span>
246    id
247<span '
248                . 'class="tok-kw">FROM</span>
249    posts
250<span class="tok-kw">WHERE</span>
251    slug = <span class="hole tone-danger" title="This is a gap: exter'
252                . 'nal input fills it. Written as $_GET[&quot;s&quot;].">{$}</span></pre></a><p class="row-meta"><a href="files/src-a-php.html">src/a.php:4</a>'
253                . '<a href="classes/app-r.html#fn-app-r-find">R::find</a><a class="chip chip-ghost" href="tables/posts.html">posts</a><span class="chip tone-da'
254                . 'nger" title="The values were followed to runtime input, so the text cannot be fixed.">external-input</span><span class="chip tone-danger" ti'
255                . 'tle="The most serious finding on this statement">high</span></p></li></ol></section><section class="group" id="rule-placeholder-count-mismat'
256                . 'ch"><h2><code>placeholder-count-mismatch</code><span class="chip tone-warn">medium</span><span class="count">1 statement</span><a class="anc'
257                . 'hor" href="#rule-placeholder-count-mismatch">#</a></h2><p class="lede">The statement binds a different number of values than it has placehol'
258                . 'ders.</p><ol class="rows"><li class="row" data-kind="update" data-resolution="resolved" data-severity="medium" data-rule="placeholder-count-'
259                . 'mismatch" data-sink="pdo.prepare" data-open="" data-table="posts" data-namespace="App" data-class="App\\R" data-function="App\\R::add" data-fi'
260                . 'le="src/a.php"><a class="row-main" href="statements/a3.html"><span class="chip tone-violet">UPDATE</span><pre class="code"><span class="tok-'
261                . 'kw">UPDATE</span>
262    posts
263<span class="tok-kw">SET</span>
264    title = <span class="tok-var">?</span>
265<span class="tok-kw">WHERE</span>
266   '
267                . ' id = <span class="tok-var">?</span></pre></a><p class="row-meta"><a href="files/src-a-php.html">src/a.php:14</a><a href="classes/app-r.html'
268                . '#fn-app-r-add">R::add</a><a class="chip chip-ghost" href="tables/posts.html">posts</a><span class="chip tone-warn" title="The most serious f'
269                . 'inding on this statement">medium</span></p></li></ol></section>',
270            (new FindingPage())->render($site),
271        );
272    }
273}
274