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); 1 further entry says what the derivation reached that is not a secret:
the API credential, presented as Authorization: Basic <base64 username:key> and compared at system/library/api_gateway.php:948; parsed at :906 and looked up against core's oc_api table by catalog/controller/event/api.php. It is core's own credential and core's own table: this extension mints nothing and stores nothing. 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: 1 sink site asserted here, 1 not admitted; attested over the inventory: 21 store-derived subtrees over 4 templates; unverified beyond it: everything else:
extensions/profitability_copilot/src/admin/view/template/module/profitability_copilot.twig:272 — style="width: {{ recompute.percent }}%;" puts a value in a CSS context, which nothing in Twig escapes for
module_profitability_copilot_status — the module_profitability_copilot setting. Rendered by admin/view/template/module/profitability_copilot.twig.
module_profitability_copilot_default_cogs_margin — the module_profitability_copilot setting. Rendered by admin/view/template/module/profitability_copilot.twig.
payment_rules — the payment methods the store's own orders carry, as core wrote them into oc_order.payment_method: a name a payment extension chose and a code it spells, both escaped by Twig on the way out, beside the rates out of this extension's own setting. Rendered by admin/view/template/module/profitability_copilot.twig.
fulfilment_rules — the same thing off oc_order.shipping_method, and the same escaping. Rendered by admin/view/template/module/profitability_copilot.twig.
pnl — the period's own trading figures, computed by Ledger off the stored profit rows and formatted into the store's currency; every label beside them is out of this extension's own language file. Rendered by admin/view/template/report/dashboard.twig.
kpis — the same three figures again, revenue, net profit and net margin, in the same currency formatting. Rendered by admin/view/template/report/dashboard.twig.
coverage — counts of the period's own profit rows — how many lines, how many excluded, and the two dates the period was read for, which came off the query string through Ledger::period() and are Y-m-d or nothing. Rendered by admin/view/template/report/dashboard.twig, admin/view/template/report/product.twig.
panel — the Not-configured findings: this extension's own sentences with counts in them, plus the oc_order_total codes the store itself carried, named so the merchant can go and look at one. Rendered by admin/view/template/report/dashboard.twig, admin/view/template/report/product.twig.
products — the period's own per-product trading figures, computed by Ledger off the stored profit rows and formatted into the store's currency, each carrying the product name and model as oc_order_product froze them on the order. Rendered by admin/view/template/report/product.twig.
totals — the same figures added up over the products that have a cost, in the same currency formatting — the row the dashboard's profit and loss prints as its own. Rendered by admin/view/template/report/product.twig.
bases — the four cost bases this extension declares, each with its own label out of this extension's language file and the one the query string asked for marked. Rendered by admin/view/template/report/product.twig.
subject_product — the drilled-into product's name, as oc_order_product froze it on the order. Rendered by admin/view/template/report/product.twig.
order_statuses — the store's own oc_order_status rows, each rendered as a checkbox label and an id. Rendered by admin/view/template/module/profitability_copilot.twig.
expenses — the operating expense rows the merchant typed in: a name, an amount, a frequency, two dates and two ids, every one of them rendered back into a form control through Twig's own escaping. Rendered by admin/view/template/report/expense.twig.
stores — the store's own oc_store rows, each rendered as an option label and an id, with 0 named out of this extension's language file. Rendered by admin/view/template/report/expense.twig, admin/view/template/report/dashboard.twig, admin/view/template/report/product.twig.
return_statuses — the store's own oc_return_status rows, each rendered as a checkbox label and an id. Rendered by admin/view/template/module/profitability_copilot.twig.
return_reasons — the store's own oc_return_reason rows, each rendered as a switch label and an id. Rendered by admin/view/template/module/profitability_copilot.twig.
recompute — the recalculation cursor as the runner last wrote it: two Y-m-d dates, four counts and a percentage, every one of them a figure this extension computed and normalised through Recompute::decode() before it reached the template. Rendered by admin/view/template/module/profitability_copilot.twig.
recompute_window — the two Y-m-d dates the opt-in back-fill would cover, from the clock. Rendered by admin/view/template/module/profitability_copilot.twig.
backfill_window — the same two dates on the dashboard's empty state, where the offer is made. Rendered by admin/view/template/report/dashboard.twig.
unsettled — the order ids of the period whose figures did not reconcile, integers off this extension's own profit table, each beside a link to core's order screen built by $this->url->link(). Rendered by admin/view/template/report/dashboard.twig.
Unverified beyond it: the other 523 of 572 template expressions in 11 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 — 48 url attributes carrying a template expression, of 49 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 — 11 .twig files:
extensions/profitability_copilot/src/admin/view/template/catalog/cost.twig:47 — {{ import }} is interpolated inside a <script> element
extensions/profitability_copilot/src/admin/view/template/customer/personal_data.twig:154 — {{ erase }} is interpolated inside a <script> element
extensions/profitability_copilot/src/admin/view/template/customer/purge.twig:71 — {{ remove }} is interpolated inside a <script> element
extensions/profitability_copilot/src/admin/view/template/module/profitability_copilot.twig:306 — {{ recompute_run }} is interpolated inside a <script> element
extensions/profitability_copilot/src/admin/view/template/report/dashboard.twig:216 — {{ recheck }} is interpolated inside a <script> element
extensions/profitability_copilot/src/admin/view/template/report/expense.twig:88 — {{ expenses|length }} is interpolated inside a <script> element
extensions/profitability_copilot/src/admin/view/template/report/expense.twig:103 — {{ store.store_id }} is interpolated inside a <script> element
extensions/profitability_copilot/src/admin/view/template/report/product.twig:204 — {{ recompute_order }} is interpolated inside a <script> element
extensions/profitability_copilot/src/admin/view/template/report/product.twig:206 — {{ subject_order }} 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 — 1 way of building a statement, over 50 statements run and 41 values escaped:
a statement handed over already built — the COUNT(*) PurgeCounts::total() runs is composed by its caller and arrives as a string (admin/model/customer/purge_counts.php). Nothing in this scaffold composes one yet; the first counts() that does will interpolate a table name the declaration owns between backticks, and that mechanism belongs here beside this one
11 of the 50 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 — 75 .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 — 11 .twig files
1.3.2 Nothing runs code it assembled while running — no eval(), and no include of a path a variable decided. checked — 75 .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 — 75 .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 — 16 of 27 routes set a Content-Type of their own:
9 × Content-Type: application/json, with no charset
1 × Content-Type: text/csv; charset=utf-8, beside Content-Disposition: attachment; filename="profitability-copilot-costs.csv" and Cache-Control: no-store
2 × Content-Type: text/csv; charset=utf-8, beside Content-Disposition: attachment; filename="profitability-copilot-profit.csv" and Cache-Control: no-store
1 × application/json; charset=utf-8, from ApiAnswer::headers($_SERVER, ...) at api/v1/product_profit.php:180. Reads the raw $_GET (:50) and the raw $_SERVER (:180)
1 × application/json; charset=utf-8, from ApiAnswer::headers($_SERVER, ...) at api/v1/profit.php:199. Reads the raw $_GET (:60) and the raw $_SERVER (:199)
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)
3 × none set, and no output written
1 × none set; the method redirects and writes no body
7 × none set; the page goes out under whatever the front controller defaults to
1 × set by ApiAnswer::headers($_SERVER, ...), whose list carries Content-Type: application/json; charset=utf-8. This route reads the raw $_SERVER (api/gateway.php:45) rather than $this->request->server, because core's Request::clean() escapes every superglobal for Twig and that escaping is wrong in an API
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 — 17 call sites in 7 templates, each declared with what it writes there:
3 × .append(
14 × .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 — 75 .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 — 75 .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 27 reaches a model write; the 8 admin ones among them stand behind the user_token core checks before dispatch, and 23 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 — 75 .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 — 75 .php files:
extensions/profitability_copilot/src/admin/controller/catalog/cost.php:175 — Cost::import() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/profitability_copilot/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/profitability_copilot/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/profitability_copilot/src/admin/controller/module/profitability_copilot.php:253 — ProfitabilityCopilot::save() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/profitability_copilot/src/admin/controller/module/profitability_copilot.php:377 — ProfitabilityCopilot::install() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/profitability_copilot/src/admin/controller/module/profitability_copilot.php:582 — ProfitabilityCopilot::uninstall() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/profitability_copilot/src/admin/controller/report/expense.php:128 — Expense::save() writes through a model and never reads REQUEST_METHOD, so a GET anybody can cause does the same thing a POST does
extensions/profitability_copilot/src/admin/controller/report/recompute.php:85 — Recompute::run() 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 — 16 of 27 routes set a Content-Type of their own:
9 × Content-Type: application/json, with no charset
1 × Content-Type: text/csv; charset=utf-8, beside Content-Disposition: attachment; filename="profitability-copilot-costs.csv" and Cache-Control: no-store
2 × Content-Type: text/csv; charset=utf-8, beside Content-Disposition: attachment; filename="profitability-copilot-profit.csv" and Cache-Control: no-store
1 × application/json; charset=utf-8, from ApiAnswer::headers($_SERVER, ...) at api/v1/product_profit.php:180. Reads the raw $_GET (:50) and the raw $_SERVER (:180)
1 × application/json; charset=utf-8, from ApiAnswer::headers($_SERVER, ...) at api/v1/profit.php:199. Reads the raw $_GET (:60) and the raw $_SERVER (:199)
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)
3 × none set, and no output written
1 × none set; the method redirects and writes no body
7 × none set; the page goes out under whatever the front controller defaults to
1 × set by ApiAnswer::headers($_SERVER, ...), whose list carries Content-Type: application/json; charset=utf-8. This route reads the raw $_SERVER (api/gateway.php:45) rather than $this->request->server, because core's Request::clean() escapes every superglobal for Twig and that escaping is wrong in an API
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 27 routes:
extension/profitability_copilot/catalog/cost.import — $this->request->files['file'], a CSV of costs. The temporary name is put through is_uploaded_file() and read where it lies; nothing is moved, 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 27 routes:
extension/profitability_copilot/catalog/cost.import — $this->request->files['file'], a CSV of costs. The temporary name is put through is_uploaded_file() and read where it lies; nothing is moved, 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 — 8 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 — 8 write sites, each declared with its file:line and pinned against the token stream both ways:
3 × log file
5 × 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 — 11 .twig files
6.2.7 A masked field does not refuse a paste or shut a password manager out of it. checked — 11 .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 — 75 .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 — 27 routes: 23 admin, 4 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 — 23 admin routes: 13 pin a permission themselves, 0 at one same-class hop, 0 at two (the hop ceiling), 10 unpinned:
extensions/profitability_copilot/src/admin/controller/catalog/cost.php:70 — Cost::index() pins no permission of its own; core checks access on extension/profitability_copilot/catalog/cost before dispatch. No model write is reachable from it.
extensions/profitability_copilot/src/admin/controller/catalog/cost.php:126 — Cost::export() pins no permission of its own; core checks access on extension/profitability_copilot/catalog/cost before dispatch. No model write is reachable from it.
extensions/profitability_copilot/src/admin/controller/customer/purge.php:83 — Purge::index() pins no permission of its own; core checks access on extension/profitability_copilot/customer/purge before dispatch. No model write is reachable from it.
extensions/profitability_copilot/src/admin/controller/module/profitability_copilot.php:141 — ProfitabilityCopilot::index() pins no permission of its own; core checks access on extension/profitability_copilot/module/profitability_copilot before dispatch. No model write is reachable from it.
extensions/profitability_copilot/src/admin/controller/report/dashboard.php:86 — Dashboard::index() pins no permission of its own; core checks access on extension/profitability_copilot/report/dashboard before dispatch. No model write is reachable from it.
extensions/profitability_copilot/src/admin/controller/report/dashboard.php:243 — Dashboard::export() pins no permission of its own; core checks access on extension/profitability_copilot/report/dashboard before dispatch. No model write is reachable from it.
extensions/profitability_copilot/src/admin/controller/report/expense.php:55 — Expense::index() pins no permission of its own; core checks access on extension/profitability_copilot/report/expense before dispatch. No model write is reachable from it.
extensions/profitability_copilot/src/admin/controller/report/product.php:70 — Product::index() pins no permission of its own; core checks access on extension/profitability_copilot/report/product before dispatch. No model write is reachable from it.
extensions/profitability_copilot/src/admin/controller/report/product.php:197 — Product::export() pins no permission of its own; core checks access on extension/profitability_copilot/report/product before dispatch. No model write is reachable from it.
extensions/profitability_copilot/src/admin/controller/report/recompute.php:67 — Recompute::index() pins no permission of its own; core checks access on extension/profitability_copilot/report/recompute 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 — 2 triples over 2 of 4 catalog routes; the admin half is one line on the shared page:
extension/profitability_copilot/api/v1/profit — the merchant's own integrator, holding an oc_api credential: The API is a merchant-to-merchant surface and an OpenCart API credential cannot be scoped: it is not a user, so it carries no group, no store and no permission of its own, and there is no second API identity to partition against. That is the first disclosure on the API reference page, because it is what decides whether the merchant should switch this on at all. Selected by a filtered, cursor-paged window; there is no id parameter, because a day of a store is two values and not one
extension/profitability_copilot/api/v1/product_profit — the merchant's own integrator, holding an oc_api credential: The same unscopeable credential, and this is the route where it costs most: a product row's cost of goods is what the merchant pays their supplier, and every credential holder on the installation can read it for as long as the switch is on. Selected by a filtered, cursor-paged window; no id parameter, for the same reason
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 — 2 distinct bounds, each named by the triple it scopes:
bounded by nothing -- every store, every product, as above
bounded by nothing -- the days are resolved across every store, because the credential is the merchant's and every store of the installation is theirs to read. filter_store_id narrows the answer and authorises nothing
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 — 75 .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 — 75 .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 — 75 .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 — 0 hash uses over 0 calls to 0 hash functions:
This extension computes no hash.
12.1.1 No outbound request asks for a TLS version below 1.2, and none pins itself to one at all. checked — 75 .php files
12.2.1 An outbound request is made over TLS with the certificate verified, and never falls back to cleartext. checked — 75 .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 — 75 .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 — 11 .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 — 75 .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 — 21 store-derived subtrees reaches a template of this extension, each one written down; what a model row holds beyond them does not:
module_profitability_copilot_status — the module_profitability_copilot setting
module_profitability_copilot_default_cogs_margin — the module_profitability_copilot setting
payment_rules — the payment methods the store's own orders carry, as core wrote them into oc_order.payment_method: a name a payment extension chose and a code it spells, both escaped by Twig on the way out, beside the rates out of this extension's own setting
fulfilment_rules — the same thing off oc_order.shipping_method, and the same escaping
pnl — the period's own trading figures, computed by Ledger off the stored profit rows and formatted into the store's currency; every label beside them is out of this extension's own language file
kpis — the same three figures again, revenue, net profit and net margin, in the same currency formatting
coverage — counts of the period's own profit rows — how many lines, how many excluded, and the two dates the period was read for, which came off the query string through Ledger::period() and are Y-m-d or nothing
panel — the Not-configured findings: this extension's own sentences with counts in them, plus the oc_order_total codes the store itself carried, named so the merchant can go and look at one
products — the period's own per-product trading figures, computed by Ledger off the stored profit rows and formatted into the store's currency, each carrying the product name and model as oc_order_product froze them on the order
totals — the same figures added up over the products that have a cost, in the same currency formatting — the row the dashboard's profit and loss prints as its own
bases — the four cost bases this extension declares, each with its own label out of this extension's language file and the one the query string asked for marked
subject_product — the drilled-into product's name, as oc_order_product froze it on the order
order_statuses — the store's own oc_order_status rows, each rendered as a checkbox label and an id
expenses — the operating expense rows the merchant typed in: a name, an amount, a frequency, two dates and two ids, every one of them rendered back into a form control through Twig's own escaping
stores — the store's own oc_store rows, each rendered as an option label and an id, with 0 named out of this extension's language file
return_statuses — the store's own oc_return_status rows, each rendered as a checkbox label and an id
return_reasons — the store's own oc_return_reason rows, each rendered as a switch label and an id
recompute — the recalculation cursor as the runner last wrote it: two Y-m-d dates, four counts and a percentage, every one of them a figure this extension computed and normalised through Recompute::decode() before it reached the template
recompute_window — the two Y-m-d dates the opt-in back-fill would cover, from the clock
backfill_window — the same two dates on the dashboard's empty state, where the offer is made
unsettled — the order ids of the period whose figures did not reconcile, integers off this extension's own profit table, each beside a link to core's order screen built by $this->url->link()
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 — 75 .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 — 75 .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 — 75 .php files