Skip to content

Security verdict

21 checked, 2 not met, 22 declared, 0 not checked — 45 controls in the baseline.

Each control below is defined on the security baseline, which also says what each of the four states means. declared is not a pass.

Control What it checks State, scope and what is left
KYV-1 A secret an untrusted caller presents is compared in constant time and refused when it is unset — and where this extension mints it rather than taking core's or a merchant's, it carries at least 128 bits from a cryptographic random source. declared — 1 bearer-secret surface over 3 mint, compare and refuse sites (2 × mint, 1 × constant-time compare, 0 × compare, 0 × refusal); 2 further entries say what the derivation reached that is not a secret:
the HTTP Basic key on the two api/ routes — core's own oc_api credential, read at system/library/api_gateway.php:850-881 from HTTP_AUTHORIZATION, REDIRECT_HTTP_AUTHORIZATION, PHP_AUTH_USER/PHP_AUTH_PW and apache_request_headers() in that order, and compared at api_gateway.php:948 with hash_equals against a dummy of equal length where the username is unknown. Compared in constant time at system/library/api_gateway.php:948. No refusal of an unset value sits in this extension's own code. Minted by core, so no entropy is asserted here: nothing in this baseline rests on core's own token helper.
1.2.1 Store data meets markup safely where the danger is decidable — an unquoted attribute, a URL the template composed itself, a style, hand-built XML — and every store-derived subtree a template of this extension renders is written down beside the code. Beyond those two, nothing is claimed, and the page says so. declared — machine-pass on the sinks: 0 sink sites asserted here, 0 not admitted; attested over the inventory: 32 store-derived subtrees over 6 templates; unverified beyond it: everything else:
module_product_search_status — setting. Rendered by admin/view/template/module/product_search.twig.
module_product_search_log_status — setting. Rendered by admin/view/template/module/product_search.twig.
module_product_search_retention — setting. Rendered by admin/view/template/module/product_search.twig.
typo_status — setting, resolved per language against oc_language. Rendered by admin/view/template/module/product_search.twig.
version_warning — the store's OpenCart release, through Compatibility. Rendered by admin/view/template/module/product_search.twig.
synonyms — database — product_search_synonym rows, plus one unsaved row prefilled from the request. Rendered by admin/view/template/module/product_search.twig.
languages — database — core's oc_language, unfiltered. Rendered by admin/view/template/module/product_search.twig, admin/view/template/module/product_search_rule.twig.
language_readout — rendered partial over oc_language. Rendered by admin/view/template/module/product_search.twig.
resolution — rendered from oc_language, through LanguageReadout. Rendered by admin/view/template/module/language_readout.twig.
stores — database — core's oc_store, with store 0 named from the language file. Rendered by admin/view/template/module/product_search.twig.
report — rendered partial over product_search_stat rows. Rendered by admin/view/template/module/product_search.twig.
rules — rendered partial over product_search_merchandising rows. Rendered by admin/view/template/module/product_search.twig.
misses — a count over product_search_stat, into a language string. Rendered by admin/view/template/module/product_search.twig.
tab — request — get[tab], narrowed to four literals. Rendered by admin/view/template/module/product_search.twig.
prefill — request — the index of the row get[keyword] appended, or the empty string. Rendered by admin/view/template/module/product_search.twig.
filter_keyword — request — get[filter_keyword], normalised through Rule::keyword(). Rendered by admin/view/template/module/product_search.twig.
filter_rule_language_id — request — get[filter_rule_language_id], cast to int. Rendered by admin/view/template/module/product_search.twig.
keywords — database — product_search_stat rows, joined to the shopper's own search terms. Rendered by admin/view/template/module/product_search_report.twig.
results — a row count, into a language string. Rendered by admin/view/template/module/product_search_report.twig, admin/view/template/module/product_search_rules.twig.
filter_store_id — request — get[filter_store_id], cast to int. Rendered by admin/view/template/module/product_search.twig.
filter_language_id — request — get[filter_language_id], cast to int. Rendered by admin/view/template/module/product_search.twig.
filter_date_start — request — get[filter_date_start]. Rendered by admin/view/template/module/product_search.twig.
filter_date_end — request — get[filter_date_end]. Rendered by admin/view/template/module/product_search.twig.
filter_zero — request — get[filter_zero], cast to int. Rendered by admin/view/template/module/product_search.twig.
merchandising_id — request — get[merchandising_id], cast to int. Rendered by admin/view/template/module/product_search_rule.twig, admin/view/template/module/product_search_rules.twig.
language_id — database row, or the request where the form is new. Rendered by admin/view/template/module/product_search_rule.twig.
keyword — database — the rule's own keyword, or the request where the form is new. Rendered by admin/view/template/module/product_search_rule.twig, admin/view/template/module/product_search_report.twig, admin/view/template/module/product_search_rules.twig.
status — database — the rule's own status, or the request where the form is new. Rendered by admin/view/template/module/product_search_rule.twig, admin/view/template/module/product_search_rules.twig.
zero_result — database — whether the rule's keyword is the zero-result rule's, or get[zero_result] where the form is new, cast to bool. Rendered by admin/view/template/module/product_search_rule.twig.
zero_results — database — each language's zero-result rule from product_search_merchandising, with oc_language names and language strings where it is not set. Rendered by admin/view/template/module/product_search_rules.twig.
pins — database — the rule's pin list in product_search_merchandising.products, joined to core's oc_product_description. Rendered by admin/view/template/module/product_search_rule.twig.
api_panel — settings and database — whether the API is on, and core's own oc_api rows. Rendered by admin/view/template/module/api_panel.twig, admin/view/template/module/product_search.twig.
Unverified beyond it: the other 361 of 385 template expressions in 10 templates, and any $data subtree nobody enumerated. The inventory is an inventory and not a bound: completeness over the whole expression surface is unverifiable, so this residual is permanent, and it is published rather than left to be inferred from what is missing.
1.2.2 A URL a template builds for itself, rather than taking one whole from the link helper, has every value in it URL-encoded — so nothing a store holds can add a parameter of its own or change where the link goes. declared — 24 url attributes carrying a template expression, of 24 sink sites asserted:
Every url attribute in this extension's templates takes its value whole from the link helper.
1.2.3 No template expression is interpolated into a <script> element, so store data cannot end a string literal and start running. not met — 10 .twig files:
extensions/product_search/src/admin/view/template/customer/personal_data.twig:154 — {{ erase }} is interpolated inside a <script> element
extensions/product_search/src/admin/view/template/customer/purge.twig:71 — {{ remove }} is interpolated inside a <script> element
extensions/product_search/src/admin/view/template/module/product_search.twig:313 — {{ synonyms|length }} is interpolated inside a <script> element
extensions/product_search/src/admin/view/template/module/product_search.twig:319 — {{ language.language_id }} is interpolated inside a <script> element
extensions/product_search/src/admin/view/template/module/product_search.twig:347 — {{ export_synonyms }} is interpolated inside a <script> element
extensions/product_search/src/admin/view/template/module/product_search.twig:367 — {{ import_synonyms }} is interpolated inside a <script> element
extensions/product_search/src/admin/view/template/module/product_search.twig:390 — {{ reload_synonyms }} is interpolated inside a <script> element
extensions/product_search/src/admin/view/template/module/product_search.twig:428 — {{ list }} is interpolated inside a <script> element
extensions/product_search/src/admin/view/template/module/product_search.twig:443 — {{ prefill }} is interpolated inside a <script> element
extensions/product_search/src/admin/view/template/module/product_search.twig:465 — {{ merchandising }} is interpolated inside a <script> element
extensions/product_search/src/admin/view/template/module/product_search.twig:480 — {{ rule_delete }} is interpolated inside a <script> element
extensions/product_search/src/admin/view/template/module/product_search.twig:494 — {{ merchandising }} is interpolated inside a <script> element
extensions/product_search/src/admin/view/template/module/product_search.twig:515 — {{ clear }} is interpolated inside a <script> element
extensions/product_search/src/admin/view/template/module/product_search.twig:532 — {{ list }} is interpolated inside a <script> element
extensions/product_search/src/admin/view/template/module/product_search_rule.twig:100 — {{ pins|length }} is interpolated inside a <script> element
extensions/product_search/src/admin/view/template/module/product_search_rule.twig:105 — {{ autocomplete }} is interpolated inside a <script> element
1.2.4 Every way this extension builds a database statement is written down beside the code, so how a value reaches a query is a published answer rather than something to go looking for. declared — 3 ways of building a statement, over 43 statements run and 19 values escaped:
escaped literal: every value this extension puts in a statement — Through $this->db->escape(), or an (int) cast where the value is numeric. Predicate takes the escaper as a constructor closure so the clause builder stays unit-testable without a database. The API's reads in catalog/model/api/product_search.php are built the same way: its ids and filters cast to (int), its two date filters and the cursor's day cut to ten characters and escaped, and its order a literal.
interpolated identifier: the table name in the SHOW TABLES / SHOW COLUMNS / SHOW INDEX probes — DB_PREFIX concatenated with a table name that is a literal in this extension's own source — Schema's constant list in the installer at admin/model/module/product_search.php:419-460, and the two product / product_code probes the search listener asks the schema where a product code lives with. Nothing from the request reaches either.
whole-clause concatenation: the search statement Replacement rebuilds in core's place — The WHERE, ORDER BY and LIMIT are assembled as whole clauses from Predicate and Replacement. The sort column is narrowed to Replacement::SORTS by in_array() before it is concatenated (Replacement::sort(), system/library/replacement.php); the limit is two integers.
15 of the 43 statements are handed over already built, so what a rule reading the call site alone can see stops there; which mechanism built them is what the lines above say.
1.2.5 Nothing runs a command through the shell — no backtick, no exec() — so no value a store holds can become part of one. checked — 65 .php files
1.3.1 No screen binds a rich-text editor whose HTML this extension would then render back out, because nothing here sanitises HTML and no sanitiser ships with it. checked — 10 .twig files
1.3.2 Nothing runs code it assembled while running — no eval(), and no include of a path a variable decided. checked — 65 .php files
1.5.1 Every XML parser is left at the restrictive default: nothing turns on external entity resolution, which is what would turn reading a spreadsheet into reading your server's files. checked — 65 .php files
3.2.1 Every route declares the response type it sets, as the code sets it, so nothing is left for a browser to re-interpret as something it is not. declared — 13 of 22 routes set a Content-Type of their own:
1 × Content-Type: text/csv; charset=utf-8, beside Content-Disposition: attachment; filename="product-search-report----[-zero].csv" — ids and dates only — and Cache-Control: no-store. The keyword column is shopper-typed, so every keyword cell goes through Report::cell(), which prefixes an apostrophe to one starting =, +, -, @, a tab or a carriage return, so a spreadsheet opens it as text rather than as a formula
1 × Content-Type: text/csv; charset=utf-8, beside Content-Disposition: attachment; filename="product-search-synonyms-.csv" and Cache-Control: no-store. No formula neutralisation: the terms are the merchant's own and the file exists to come back through the import unchanged
5 × application/json
1 × application/json, with no charset (customer/personal_data.php:230)
1 × application/json, with no charset (customer/personal_data.php:272), sent as an attachment named for the person and the day (customer/personal_data.php:299)
1 × application/json, with no charset (customer/personal_data.php:359)
1 × application/json, with no charset (customer/purge.php:168)
2 × application/json; charset=utf-8, with X-Content-Type-Options: nosniff and Access-Control-Allow-Origin: null
1 × none set, and no output written
8 × none — nothing sets a Content-Type, so the store's default stands
3.2.2 Every place a script hands a value to the page as markup rather than as text is written down beside the code, with what it puts there. declared — 16 call sites in 4 templates, each declared with what it writes there:
5 × .append(
11 × .prepend(
3.3.1 A cookie this extension sets carries the Secure attribute at the call that sets it, so a browser cannot send it back over plain HTTP. checked — 65 .php files
3.4.2 A cross-origin header is a fixed value this code chose — never a wildcard, and never the origin the caller asked for. checked — 65 .php files
3.5.1 Every route that changes something says what stands between it and a request another website caused a visitor's browser to make. declared — 8 routes of 22 reaches a model write; the 8 admin ones among them stand behind the user_token core checks before dispatch, and 19 admin routes are gated that way in all:
No storefront route of this extension reaches a model write.
3.5.2 No route grants a cross-origin caller anything, so nothing here is left depending on a browser's preflight to refuse one. checked — 65 .php files
3.5.3 A route that writes refuses a request that is not a POST, so a link somebody follows cannot make the change on their behalf. not met — 65 .php files:
extensions/product_search/src/admin/controller/customer/personal_data.php:321 — PersonalData::grant() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/product_search/src/admin/controller/customer/purge.php:147 — Purge::remove() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/product_search/src/admin/controller/module/product_search.php:450 — ProductSearch::save() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/product_search/src/admin/controller/module/product_search.php:672 — ProductSearch::importSynonyms() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/product_search/src/admin/controller/module/product_search.php:892 — ProductSearch::saveRule() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/product_search/src/admin/controller/module/product_search.php:958 — ProductSearch::deleteRule() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/product_search/src/admin/controller/module/product_search.php:995 — ProductSearch::install() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/product_search/src/admin/controller/module/product_search.php:1133 — ProductSearch::uninstall() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
4.1.1 A response carrying a body says what that body is, and the route table records the Content-Type each route sets rather than the one it ought to. declared — 13 of 22 routes set a Content-Type of their own:
1 × Content-Type: text/csv; charset=utf-8, beside Content-Disposition: attachment; filename="product-search-report----[-zero].csv" — ids and dates only — and Cache-Control: no-store. The keyword column is shopper-typed, so every keyword cell goes through Report::cell(), which prefixes an apostrophe to one starting =, +, -, @, a tab or a carriage return, so a spreadsheet opens it as text rather than as a formula
1 × Content-Type: text/csv; charset=utf-8, beside Content-Disposition: attachment; filename="product-search-synonyms-.csv" and Cache-Control: no-store. No formula neutralisation: the terms are the merchant's own and the file exists to come back through the import unchanged
5 × application/json
1 × application/json, with no charset (customer/personal_data.php:230)
1 × application/json, with no charset (customer/personal_data.php:272), sent as an attachment named for the person and the day (customer/personal_data.php:299)
1 × application/json, with no charset (customer/personal_data.php:359)
1 × application/json, with no charset (customer/purge.php:168)
2 × application/json; charset=utf-8, with X-Content-Type-Options: nosniff and Access-Control-Allow-Origin: null
1 × none set, and no output written
8 × none — nothing sets a Content-Type, so the store's default stands
5.2.1 An upload is accepted on the server's terms — what the bytes are, not what the caller said they were — and every surface that takes one is declared. declared — 1 upload surface across 22 routes:
extension/product_search/module/product_search.importSynonyms — $this->request->files['file'], a CSV of one language's synonym groups. The temporary name is put through is_uploaded_file() and read where it lies with fgetcsv(); nothing is moved or stored, and no name from the upload reaches a path
5.2.2 An uploaded file is stored under a name the server chose, so nothing the caller named decides where it lands. declared — 1 upload surface across 22 routes:
extension/product_search/module/product_search.importSynonyms — $this->request->files['file'], a CSV of one language's synonym groups. The temporary name is put through is_uploaded_file() and read where it lies with fgetcsv(); nothing is moved or stored, and no name from the upload reaches a path
5.3.1 Every file this extension writes says whether a browser can fetch it, and nothing it writes where a browser can reach is program code. declared — 5 write sites, 3 of them fetchable by a browser:
system/library/diary.php:437 — kyvero.log in the store's own log directory — the DIR_LOGS this class is handed, with no part of the name coming from a request — one record appended per write, at system/library/diary.php:437
system/library/diary.php:470 — the same kyvero.log, opened r+ to trim it back under the 1 MiB cap, at system/library/diary.php:470
system/library/diary.php:495 — the same kyvero.log, rewritten to what a trim kept — oldest-first, on a line boundary, under an exclusive non-blocking lock — at system/library/diary.php:495
5.3.2 Every path this extension writes to is written down beside the code, with where the name in it came from. declared — 5 write sites, each declared with its file:line and pinned against the token stream both ways:
3 × log file
2 × stream
6.2.6 A field that takes a password or a key is masked, so it is not left readable on the screen or in a screenshot of it. checked — 10 .twig files
6.2.7 A masked field does not refuse a paste or shut a password manager out of it. checked — 10 .twig files
6.3.2 No credential is written into the source — no default account, and no password or key a reader of the shipped files could use. checked — 65 .php files
8.1.1 Every route the extension answers is written down beside the code, with what guards it — and the gate refuses a route nobody wrote down and a written-down route nothing answers. declared — 22 routes: 19 admin, 3 catalog, each declared beside the code
8.2.1 An admin route that changes something tests the permission itself, in a condition that can refuse — and a route that only reads says so, standing behind the check OpenCart makes before dispatch. declared — 19 admin routes: 12 pin a permission themselves, 0 at one same-class hop, 0 at two (the hop ceiling), 7 unpinned:
extensions/product_search/src/admin/controller/customer/purge.php:83 — Purge::index() pins no permission of its own; core checks access on extension/product_search/customer/purge before dispatch. No model write is reachable from it.
extensions/product_search/src/admin/controller/module/product_search.php:219 — ProductSearch::index() pins no permission of its own; core checks access on extension/product_search/module/product_search before dispatch. No model write is reachable from it.
extensions/product_search/src/admin/controller/module/product_search.php:573 — ProductSearch::list() pins no permission of its own; core checks access on extension/product_search/module/product_search before dispatch. No model write is reachable from it.
extensions/product_search/src/admin/controller/module/product_search.php:629 — ProductSearch::export() pins no permission of its own; core checks access on extension/product_search/module/product_search before dispatch. No model write is reachable from it.
extensions/product_search/src/admin/controller/module/product_search.php:647 — ProductSearch::exportSynonyms() pins no permission of its own; core checks access on extension/product_search/module/product_search before dispatch. No model write is reachable from it.
extensions/product_search/src/admin/controller/module/product_search.php:777 — ProductSearch::merchandising() pins no permission of its own; core checks access on extension/product_search/module/product_search before dispatch. No model write is reachable from it.
extensions/product_search/src/admin/controller/module/product_search.php:802 — ProductSearch::form() pins no permission of its own; core checks access on extension/product_search/module/product_search before dispatch. No model write is reachable from it.
8.2.2 A storefront route that reaches a record says which caller may reach which records, and what selects one — so reaching somebody else's is a question with a written answer. declared — 3 triples over 2 of 3 catalog routes; the admin half is one line on the shared page:
extension/product_search/api/gateway.fail — a holder of one of core's oc_api credentials: Absent, unknown, duplicated, disabled, wrong-keyed and address-not-listed are one refusal with one code and one message, so the envelope partitions nothing. Selected by none — not a record; this route renders a refusal and reads nothing
extension/product_search/api/v1/term — a holder of one of core's oc_api credentials: The credential cannot be scoped, which costs little here: a counter row holds a keyword, a day and two numbers, and nothing about who searched. Synonyms and merchandising rules are not resources. Selected by term_id
extension/product_search/api/v1/term — a holder of one of core's oc_api credentials, walking the collection: filter_store_id is a convenience for a caller who wants one store, not a boundary: the same credential may simply leave it off. Selected by none — not a record; the filters and the cursor are a page of a walk rather than a record selector
8.3.1 What bounds a caller to their own records comes from the server — a session, a stored row, the store id — and never from a value the caller supplied. declared — 3 distinct bounds, each named by the triple it scopes:
bounded by nothing — an OpenCart API user opens every store in the installation, and this resource does not narrow that
bounded by nothing — a filter is the caller narrowing their own read, never the store narrowing what they may see
bounded by nothing — the refusal is the same for every caller
9.1.1 A secret that carries its own claim — an identity inside the string rather than a row to look up — is only believed after the signature beside it has been checked. declared — 0 self-contained surfaces of 1 bearer-secret surface
9.1.2 Every hashing algorithm is a literal in the source, from a fixed allowlist, so nothing arriving in a request can choose a weaker one. checked — 65 .php files
9.1.3 The key a signed secret is checked against comes from somewhere this extension was configured with, never from anything inside the secret itself. declared — 1 bearer-secret surface, from core — never from anything inside the secret presented:
system/library/api_gateway.php:948 — core
9.2.1 A secret that carries its own expiry is accepted only inside it, and the declaration says which ones carry one. declared — 0 surfaces of 1 bearer-secret surface could carry a validity span inside the secret itself; the rest are a reference to a row, whose expiry is a column on it rather than a claim the caller presents:
No secret this extension accepts carries its own validity span.
11.3.1 Nothing encrypts with a broken mode or padding — no ECB, no PKCS#1 v1.5. checked — 65 .php files
11.3.2 Where anything is encrypted, the cipher is a literal in the source from a short allowlist, so nothing arriving in a request can choose a weaker one. checked — 65 .php files
11.4.1 Every hash this extension computes is written down with what it is for, so a hash naming a cache entry is not read as one standing in front of a secret. declared — 1 hash use over 2 calls to 1 hash function:
md5: the per-request memo key for a search — md5(json_encode(...)) over the model's own filter array, at catalog/controller/event/search.php:236 and system/library/merchandising.php:213. An in-memory array key for one request, never stored, never compared against anything a caller sends.
12.1.1 No outbound request asks for a TLS version below 1.2, and none pins itself to one at all. checked — 65 .php files
12.2.1 An outbound request is made over TLS with the certificate verified, and never falls back to cleartext. checked — 65 .php files
12.2.2 An outbound request trusts your server's own certificate store: nothing here bundles a certificate authority of its own or turns verification off. checked — 65 .php files
14.2.1 A credential is not carried in a URL, where a browser history, a referrer header and a proxy log each keep their own copy of it. declared — 0 bearer-secret surfaces of 1 travel in a URL
14.3.1 Nothing is left behind in the browser's own storage for the next person at that computer to read. checked — 10 .twig files
15.2.1 The extension bundles no third-party library, so there is nothing inside it for you to keep patched other than our own code. checked — 65 .php files
15.3.1 What reaches a page is an enumerated set of values rather than whole database rows handed over wholesale, and every one of them is written down. declared — 32 store-derived subtrees reaches a template of this extension, each one written down; what a model row holds beyond them does not:
module_product_search_status — setting
module_product_search_log_status — setting
module_product_search_retention — setting
typo_status — setting, resolved per language against oc_language
version_warning — the store's OpenCart release, through Compatibility
synonyms — database — product_search_synonym rows, plus one unsaved row prefilled from the request
languages — database — core's oc_language, unfiltered
language_readout — rendered partial over oc_language
resolution — rendered from oc_language, through LanguageReadout
stores — database — core's oc_store, with store 0 named from the language file
report — rendered partial over product_search_stat rows
rules — rendered partial over product_search_merchandising rows
misses — a count over product_search_stat, into a language string
tab — request — get[tab], narrowed to four literals
prefill — request — the index of the row get[keyword] appended, or the empty string
filter_keyword — request — get[filter_keyword], normalised through Rule::keyword()
filter_rule_language_id — request — get[filter_rule_language_id], cast to int
keywords — database — product_search_stat rows, joined to the shopper's own search terms
results — a row count, into a language string
filter_store_id — request — get[filter_store_id], cast to int
filter_language_id — request — get[filter_language_id], cast to int
filter_date_start — request — get[filter_date_start]
filter_date_end — request — get[filter_date_end]
filter_zero — request — get[filter_zero], cast to int
merchandising_id — request — get[merchandising_id], cast to int
language_id — database row, or the request where the form is new
keyword — database — the rule's own keyword, or the request where the form is new
status — database — the rule's own status, or the request where the form is new
zero_result — database — whether the rule's keyword is the zero-result rule's, or get[zero_result] where the form is new, cast to bool
zero_results — database — each language's zero-result rule from product_search_merchandising, with oc_language names and language strings where it is not set
pins — database — the rule's pin list in product_search_merchandising.products, joined to core's oc_product_description
api_panel — settings and database — whether the API is on, and core's own oc_api rows
16.2.5 No log line names a credential — no token, secret, signature or password is written into the file the error log screen renders. checked — 65 .php files
16.4.1 Everything written to the error log is escaped first, so nothing a store holds can forge a record or close the box a merchant reads the log in. checked — 65 .php files
16.5.1 No error message carrying internal detail — a database driver puts the failing statement in one — is thrown onward or rendered to a response. checked — 65 .php files