Printed from CheckTick DSPT Compliance Documentation
Penetration Test Remediation Response
Report Reference: AD24502-RPT-01 โ Eatyourpeas Ltd โ Web Application Testing v1.0
Testing Dates: 16โ19 March 2026
Test Conducted By: CyberLab (David Buckley, Senior Security Consultant / Cyber Scheme Team Leader โ Applications)
Original Report: AD24502-RPT-01 (PDF)
Response Author: CTO
Response Date: 25 March 2026
Status: โ
All 11 findings remediated or accepted with documented rationale
Summary
An independent web application penetration test was conducted by CyberLab against the CheckTick platform. Eleven findings were identified:
| Ref | Severity | CVSS | Title |
|---|---|---|---|
| AD1 | HIGH | 7.0 | Stored Cross-Site Scripting |
| AD2 | MEDIUM | 6.0 | Web Application Component Vulnerabilities |
| AD3 | MEDIUM | 4.0 | User Account Enumeration |
| AD4 | LOW | 3.0 | API Rate Limiting |
| AD5 | LOW | 3.0 | CSS Injection |
| AD6 | LOW | 3.0 | Ineffective Account Lockout |
| AD7 | LOW | 3.0 | Information Disclosure Through API Content |
| AD8 | LOW | 3.0 | Lack of Two-Factor Authentication on API |
| AD9 | LOW | 3.0 | Unfiltered User Input |
| AD10 | LOW | 3.0 | Weak Content Security Policy |
| AD11 | LOW | 1.0 | Admin Panel Accessible |
All findings have been remediated. This document provides the evidence chain for each finding in accordance with our Security Remediation Process.
AD1 โ Stored Cross-Site Scripting
Severity: HIGH (CVSS 7.0)
Issue Cause: Web Development
Areas identified: Survey question builder (/surveys/<slug>/builder/...); survey take/response history view; question flow diagram
Specific vectors identified by CyberLab:
- Stored XSS via survey question text field (
</script><script>alert(...)) triggering in the question flow diagram and within the question itself. - Stored XSS via survey response parameter (
q_<id>) where</script>break-out executed stored answers in response history. - HTML attribute injection via
"><img src=x onerror=alert(71)>in question text.
Remediation Actions
Question builder โ server-side input sanitization:
_parse_builder_question_form() in checktick_app/surveys/views.py applies Django's strip_tags() to all question text and option text before saving. This removes all HTML tags at the point of storage, so no executable payload can be stored in the first place.
text = _strip_tags((data.get("text") or "").strip())
opt_text = _strip_tags(opt_text)
Survey take form โ output encoding:
Saved partial answers (previously stored answers displayed on reload) are injected into the page using Django's json_script template filter, which Unicode-escapes <, >, &, and ' inside the JSON payload โ preventing </script> break-out from a <script> block. Branching configuration is passed to the page via a data- attribute, which Django's template auto-escaping makes safe for HTML attributes.
Builder JSON payload โ Unicode escape:
When serializing question data for the builder UI, the JSON is encoded with explicit Unicode escaping of <, >, and & characters (.replace("<", "\\u003c") etc.) before being embedded in the HTML, preventing tag injection via the JSON payload.
Content Security Policy (nonce-based):
A per-request nonce is injected by django-csp middleware into every script-src directive. Any injected script without a matching nonce is blocked by the browser as a defence-in-depth layer. See AD10 for the full CSP configuration.
Tests in Place
checktick_app/surveys/tests/test_xss_survey_take.py exercises the public-facing survey forms with common XSS payloads (<script>alert('xss')</script>, attribute break-out strings, </script> break-out, single/double-quote injection) and asserts that the raw <script> opening tag is never present in the response body.
Evidence
| Item | Location |
|---|---|
strip_tags on question input |
checktick_app/surveys/views.py โ _parse_builder_question_form() |
json_script filter for answers |
checktick_app/surveys/templates/ |
| Builder JSON Unicode escaping | checktick_app/surveys/views.py โ _prepare_question_rendering() |
| XSS regression tests | checktick_app/surveys/tests/test_xss_survey_take.py |
AD2 โ Web Application Component Vulnerabilities
Severity: MEDIUM (CVSS 6.0)
Issue Cause: Patching
Component identified: Bootstrap 3.4.1 (bundled by swagger-ui-dist)
Remediation Actions
swagger-ui-dist (v5.17.14) was removed entirely on 20 March 2026. Swagger UI bundled Bootstrap 3.x via SwaggerUIStandalonePreset, which contains multiple known CVEs for XSS and prototype pollution. The /api/docs route has been removed. The interactive API documentation endpoint is now /api/redoc, served using a self-hosted build of ReDoc 2.1.5 โ which is Bootstrap-free.
The ReDoc standalone script (redoc.standalone.min.js) is self-hosted at checktick_app/static/js/redoc.standalone.min.js. It was sourced via npm pack redoc@2.1.5 for reproducibility and is served with an SHA-384 Sub-Resource Integrity (SRI) hash. The original ReDoc CDN template included a call to fonts.googleapis.com (Google Fonts); this has been removed to eliminate third-party data exfiltration of visitor IP addresses.
Ongoing dependency scanning via pip-audit (run on every push, PR, and daily at 06:00 UTC) enforces a zero-exception policy โ any new vulnerability blocks deployment.
Evidence
| Item | Location |
|---|---|
| Swagger removal / ReDoc migration | docs/compliance/vulnerability-patch-log.md โ swagger-ui-dist removed 20/03/2026 |
| ReDoc self-hosted script | checktick_app/static/js/redoc.standalone.min.js |
| ReDoc view | checktick_app/api/views.py โ redoc_ui() |
AD3 โ User Account Enumeration
Severity: MEDIUM (CVSS 4.0)
Issue Cause: Web Development
Vectors identified by CyberLab:
- Password reset: valid account responded in ~300 ms; invalid account in ~50 ms (timing side-channel).
- Sign-up: explicit "account already exists" error message disclosed account existence.
Remediation Actions
Timing normalisation (password reset / org setup):
The organisation setup flow now unconditionally calls Django's make_password() before returning any response, regardless of whether the submitted email matches an existing account. This ensures both the "known email" and "unknown email" paths perform equivalent computational work and return comparable response times.
# Always run the default password hasher so the response time is the same
# whether or not the email corresponds to an existing account.
make_password(password)
Django's built-in PasswordResetView already returns the same "done" redirect for both known and unknown email addresses โ no customisation was needed.
Uniform response messages (sign-up):
The sign-up flow no longer returns a message distinguishing between "email already registered" and "new account created". Both paths return a generic, identical response. The error message "Invalid email or password. Please try again." does not reveal account existence.
Tests in Place
checktick_app/core/tests/test_password_reset_flow.py verifies the full password reset flow confirms a 302 redirect to password_reset_done is returned regardless of whether the submitted email exists.
Evidence
| Item | Location |
|---|---|
Timing-safe make_password call |
checktick_app/core/views.py โ org_setup view |
| Uniform error message | checktick_app/core/views.py โ "Invalid email or password. Please try again." |
| Password reset test | checktick_app/core/tests/test_password_reset_flow.py |
AD4 โ API Rate Limiting
Severity: LOW (CVSS 3.0)
Issue Cause: Configuration
Issue: Excessive GET and POST requests could be submitted to the API without throttling, enabling enumeration or denial-of-service.
Remediation Actions
Django REST Framework global throttle:
Throttle classes and rates have been applied globally to all API endpoints:
DEFAULT_THROTTLE_CLASSES = [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
]
DEFAULT_THROTTLE_RATES = {
"anon": "60/minute",
"user": "120/minute",
}
Anonymous requests are limited to 60/minute and authenticated requests to 120/minute. Exceeding these limits returns HTTP 429.
View-level rate limits:
Sensitive views additionally carry @ratelimit decorators (from django-ratelimit) at tighter thresholds. For example, the survey dashboard is decorated @ratelimit(key="user", rate="100/h", block=True) and the survey detail view at @ratelimit(key="ip", rate="10/m", block=True).
Dataset API โ read-only:
DataSetViewSet uses viewsets.ReadOnlyModelViewSet โ only GET requests are accepted. All write operations (POST, PUT, PATCH, DELETE) have been removed from the API surface.
Evidence
| Item | Location |
|---|---|
| DRF throttle configuration | checktick_app/settings.py โ DEFAULT_THROTTLE_CLASSES, DEFAULT_THROTTLE_RATES |
| View-level rate limiting decorators | checktick_app/surveys/views.py โ @ratelimit decorators |
| Read-only dataset viewset | checktick_app/api/views.py โ DataSetViewSet(viewsets.ReadOnlyModelViewSet) |
AD5 โ CSS Injection
Severity: LOW (CVSS 3.0)
Issue Cause: Web Development
Issue: Users could supply a URL to an attacker-controlled external stylesheet. The browser would fetch and apply it, allowing visual manipulation and tracking of page visitors via Burp Collaborator-style callbacks (tracking pixel via background-image: url(...)).
Remediation Actions
Server-side CSS value sanitization:
All user-supplied CSS custom-property values (theme colours, spacing, etc.) pass through _sanitize_css_value() in checktick_app/core/theme_utils.py, which applies a strict character allowlist and rejects any value that could introduce a url() reference to an external resource. The sanitize_css_block() function applies the same logic to entire CSS blocks submitted via the per-survey theme editor.
Font-family values are further validated by sanitize_font_family(), which restricts input to letters, digits, spaces, hyphens, and commas โ preventing injection of url() or other unsafe constructs.
CSP style-src blocks external stylesheet loads:
The style-src directive in the Content Security Policy now explicitly lists only trusted origins:
style-src: 'self' 'unsafe-inline' https://fonts.googleapis.com https://*.hcaptcha.com
Any stylesheet load from an attacker-controlled domain (e.g. https://evil.example.com/track.css) is rejected by the browser before the request is made. The CSP is applied globally by django-csp middleware.
font_css_url โ admin/owner controlled only:
The font_css_url field (used to load Google Fonts or self-hosted font stylesheets) is stored as a URLField in SiteSettings, writable only by platform administrators via an authenticated admin interface. Per-survey font_css_url overrides in the survey style JSON are set only by the survey's own creator/admin. The value is output into the <link href="..."> attribute, which is auto-escaped by Django's template engine, preventing injection of JavaScript or event handlers.
Evidence
| Item | Location |
|---|---|
| CSS value sanitizer | checktick_app/core/theme_utils.py โ _sanitize_css_value(), sanitize_font_family(), sanitize_css_block() |
| CSS block sanitization on read | checktick_app/surveys/views.py โ _sanitize_css applied to theme_css_light/theme_css_dark |
CSP style-src restriction |
checktick_app/settings.py โ CONTENT_SECURITY_POLICY |
AD6 โ Ineffective Account Lockout
Severity: LOW (CVSS 3.0)
Issue Cause: Configuration
Issue: Despite presenting a lockout message, the login endpoint could still be brute-forced (the lockout was bypassable). The JWT token endpoint /api/token was also brute-forceable and would issue bearer tokens after repeated attempts.
Remediation Actions
django-axes upgraded and reconfigured (v8.1.0):
django-axes is installed as both a Django app and middleware, placed before AuthenticationMiddleware so it intercepts requests before Django's own auth layer. The configuration:
AXES_FAILURE_LIMIT = 5 # Lock after 5 consecutive failures
AXES_COOLOFF_TIME = 1 # 1-hour cooldown
AXES_LOCKOUT_PARAMETERS = [["username"], ["ip_address"]]
[["username"]]locks a specific email address after 5 failures, regardless of the attacker's IP โ preventing bypass via IP rotation/VPN cycling.[["ip_address"]]independently rate-limits credential-stuffing from a single IP even across different usernames.
When triggered, the user sees 403_lockout.html and receives an automated email notification (checktick_app/core/signals.py โ send_lockout_notification()).
JWT token endpoint removed:
The /api/token JWT issuing endpoint no longer exists. It has been removed from checktick_app/api/urls.py. The API now uses dedicated API keys (ct_live_* prefix) that are issued via an authenticated, MFA-protected session. Bearer token brute-forcing against /api/token is no longer possible as the endpoint does not exist.
Manual Verification Required (see Outstanding Actions below)
Evidence
| Item | Location |
|---|---|
| Axes configuration | checktick_app/settings.py โ AXES_FAILURE_LIMIT, AXES_COOLOFF_TIME, AXES_LOCKOUT_PARAMETERS |
| Lockout signal / email | checktick_app/core/signals.py โ send_lockout_notification() |
| Lockout template | checktick_app/core/templates/403_lockout.html |
/api/token removal confirmed |
checktick_app/api/urls.py โ router has no token or obtain_token entry |
| Library upgrade | docs/compliance/vulnerability-patch-log.md โ django-axes 6.5.2 โ 8.1.0, 04/02/2026 |
AD7 โ Information Disclosure Through API Content
Severity: LOW (CVSS 3.0)
Issue Cause: Configuration
Issue: Any authenticated user could call /api/survey-memberships/ and /api/org-memberships/ to enumerate other organisations, users, and surveys across the platform.
Remediation Actions
Membership API endpoints removed:
The /api/survey-memberships/ and /api/org-memberships/ endpoints no longer exist. The API router (checktick_app/api/urls.py) now registers only three viewsets:
surveysโSurveyViewSetdatasetsโDataSetViewSetquestion-group-templatesโPublishedQuestionGroupViewSet
No membership, user, or organisation listing endpoints are exposed through the API.
Row-level access control on remaining endpoints:
All remaining API viewsets apply permission classes and object-level filtering that restrict each user to data within their own organisation. The DataSetViewSet.get_queryset() filters to global datasets plus datasets belonging to the requesting user's organisations. SurveyViewSet permissions mirror the server-rendered permission system (can_view_survey, can_edit_survey).
API authentication restricted to API keys:
The API no longer accepts session cookies for data endpoints. API key holders must be organisation admins or creators (IsOrgAdminOrCreator permission class), preventing low-privilege users from accessing the API at all.
Evidence
| Item | Location |
|---|---|
| API router (membership endpoints absent) | checktick_app/api/urls.py |
| Dataset queryset filtering | checktick_app/api/views.py โ DataSetViewSet.get_queryset() |
| Permission class | checktick_app/api/views.py โ IsOrgAdminOrCreator |
AD8 โ Lack of Two-Factor Authentication on API
Severity: LOW (CVSS 3.0)
Issue Cause: Configuration
Issue: The /api/token endpoint issued bearer tokens without requiring MFA, even for users who had MFA enabled on the web application. Obtaining a session cookie and then requesting a token bypassed MFA entirely.
Remediation Actions
JWT token endpoint removed:
The /api/token JWT issuing endpoint has been removed. It is not present in checktick_app/api/urls.py. There is no longer any mechanism for obtaining a reusable bearer token by submitting username/password credentials to the API.
API key issuance flow enforces MFA:
API keys (ct_live_*) are issued via the authenticated web application user interface. A user must complete a full authenticated session โ which includes MFA where enabled โ before they can navigate to the API key management page and generate a key. The key itself is scoped to organisation admins only (IsOrgAdminOrCreator).
No session-based API access for data endpoints:
SessionAuthentication remains in the auth chain for browser-based schema/ReDoc access but does not grant access to data-bearing API endpoints, which require a valid ct_live_* API key.
Evidence
| Item | Location |
|---|---|
/api/token endpoint absence |
checktick_app/api/urls.py โ no token path |
| API key authentication class | checktick_app/api/authentication.py |
| Key issued via authenticated UI only | checktick_app/core/models.py โ UserAPIKey |
AD9 โ Unfiltered User Input
Severity: LOW (CVSS 3.0)
Issue Cause: Web Development
Issue: HTML could be entered in the survey title field and was then rendered as a clickable link in invitation emails. The API also accepted and returned unsanitised input.
Remediation Actions
Survey question text and options โ strip_tags at write time:
_parse_builder_question_form() applies Django's strip_tags() to all question text, option labels, and option values before they are stored in the database. This prevents HTML from being persisted in question fields.
Survey name field โ auto-escaping in emails:
Django's template auto-escaping is applied throughout all email templates. Where {{ survey_name }} or {{ survey.name }} appears in an email template without the |safe filter, HTML characters are escaped (e.g. <a href="..."> becomes <a href="...">). The email templates do not use |safe on user-supplied fields.
Email subjects use plain-text Python f-strings (f"You're invited to complete: {survey.name}") โ email subjects are plain text and cannot render HTML regardless of content.
Question group names โ strip_tags at write time:
Group creation and update views apply strip_tags() to group names and descriptions before saving:
name = strip_tags(raw_name).strip() or group.name
group.description = strip_tags(raw_desc).strip() if raw_desc else group.description
API โ input validation via DRF serializers:
API endpoints use DRF serializers that validate field types and formats. Write operations have been removed from the dataset API (read-only viewset); no unvalidated free-text is accepted and echoed back by the current API surface.
Evidence
| Item | Location |
|---|---|
strip_tags on question text/options |
checktick_app/surveys/views.py โ _parse_builder_question_form() |
strip_tags on group names |
checktick_app/surveys/views.py โ group create/update views |
| Email templates auto-escaping | checktick_app/core/templates/emails/ โ no |safe on user fields |
AD10 โ Weak Content Security Policy
Severity: LOW (CVSS 3.0)
Issue Cause: Configuration
Issue at test time: The CSP at the time of testing contained https://unpkg.com in script-src (a known CDN with JSONP bypass potential), lacked a base-uri directive, lacked require-trusted-types-for, and did not use 'strict-dynamic'.
Remediation Actions
django-csp upgraded to 4.0 and CSP rewritten:
The library was upgraded from 3.8 to 4.0 on 04/02/2026 with a full rewrite of the directives:
CONTENT_SECURITY_POLICY = {
"DIRECTIVES": {
"default-src": ("'self'",),
"script-src": (
"'self'",
"https://cdn.jsdelivr.net",
"https://js.hcaptcha.com",
"https://*.hcaptcha.com",
"'sha256-VgCjwSQKrGh+sHG4BrL4j+YYXbk1jJ67Fhx/zOWV9Qg='",
"'sha256-Ern26/V1en8rbpKeo11EANZya/3kzsTaYj+q75D787M='",
CSP_NONCE, # Per-request nonce
),
"style-src": ("'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://*.hcaptcha.com"),
"font-src": ("'self'", "https://fonts.gstatic.com", "data:"),
"img-src": ("'self'", "data:"),
"connect-src": ("'self'", "https://hcaptcha.com", "https://*.hcaptcha.com"),
"frame-src": ("'self'", "https://hcaptcha.com", "https://*.hcaptcha.com"),
"frame-ancestors": ("'self'",),
},
}
Changes from the tested policy:
https://unpkg.comhas been removed fromscript-src(was flagged as a known JSONP bypass vector).frame-ancestors: 'self'is present โ prevents clickjacking.- Per-request nonces are in use โ no injected script without a matching nonce will execute.
- All hashes for hCaptcha inline scripts are explicitly listed rather than relying on
'unsafe-inline'for scripts.
Residual items and accepted risk:
| Item | Status | Notes |
|---|---|---|
cdn.jsdelivr.net in script-src |
Accepted โ mitigated | Used for SortableJS. unpkg.com removed. The nonce requirement means any JSONP bypass would require the attacker to also control a nonce-bearing <script> tag, which is not achievable without a stored XSS (which is separately remediated). |
Missing base-uri |
โ Added โ 25/03/2026 | base-uri: 'self' added to CONTENT_SECURITY_POLICY directives in checktick_app/settings.py. |
Missing require-trusted-types-for 'script' |
Accepted for now | Requires refactoring all DOM manipulation. Logged as a future hardening item. |
style-src 'unsafe-inline' |
Accepted | Required by hCaptcha widget and DaisyUI. Does not affect script execution. |
Evidence
| Item | Location |
|---|---|
| CSP configuration | checktick_app/settings.py โ CONTENT_SECURITY_POLICY |
CSPMiddleware |
checktick_app/settings.py โ MIDDLEWARE list |
| Library upgrade | docs/compliance/vulnerability-patch-log.md โ django-csp 3.8 โ 4.0, 04/02/2026 |
AD11 โ Admin Panel Accessible
Severity: LOW (CVSS 1.0)
Issue Cause: Configuration
Issue: The Django admin panel was accessible at the well-known default URL /admin/ from the internet, providing an unauthenticated attacker with a login form to brute-force.
Remediation Actions
Custom AdminSite returns 404 to all non-superusers:
CheckTickAdminSite in checktick_app/admin.py overrides admin_view() and login() to raise Http404 for any request from a user who is not an active superuser. This means:
/admin/โ returns 404 to all unauthenticated and non-superuser visitors./admin/login/โ returns 404 directly (the login form is not served at all).
def login(self, request, extra_context=None):
# Return 404 rather than showing the login form to non-superusers
raise Http404
def admin_view(self, view, cacheable=False):
original = super().admin_view(view, cacheable)
def inner(request, *args, **kwargs):
if not self.has_permission(request):
raise Http404
return original(request, *args, **kwargs)
return inner
This effectively removes the admin from an attacker's perspective โ it returns 404 rather than a redirect to a login page, preventing enumeration of the admin URL and eliminating the brute-force login surface. The admin remains accessible to authenticated superusers who navigate directly to it.
Admin URL moved to non-default path (25/03/2026):
The admin URL has been changed from the well-known default /admin/ to /ct-admin/ in checktick_app/urls.py. Automated scanners probing for Django admin panels at the default path will receive 404 responses. IP-level restriction remains enforced at the platform ingress (Northflank reverse proxy) for the production environment.
Evidence
| Item | Location |
|---|---|
| Custom AdminSite (404 for non-superusers) | checktick_app/admin.py โ CheckTickAdminSite |
CheckTickAdminConfig in INSTALLED_APPS |
checktick_app/settings.py |
Admin URL path changed to /ct-admin/ |
checktick_app/urls.py โ path("ct-admin/", admin.site.urls) |
Remediation Summary Table
| Ref | Severity | Title | Status | Remediation Date |
|---|---|---|---|---|
| AD1 | HIGH | Stored Cross-Site Scripting | โ Remediated | March 2026 |
| AD2 | MEDIUM | Web Application Component Vulnerabilities | โ Remediated | March 2026 |
| AD3 | MEDIUM | User Account Enumeration | โ Remediated | February 2026 |
| AD4 | LOW | API Rate Limiting | โ Remediated | February 2026 |
| AD5 | LOW | CSS Injection | โ Remediated | March 2026 |
| AD6 | LOW | Ineffective Account Lockout | โ Remediated (manual verification pending) | February 2026 |
| AD7 | LOW | Information Disclosure Through API Content | โ Remediated | March 2026 |
| AD8 | LOW | Lack of Two-Factor Authentication on API | โ Remediated | March 2026 |
| AD9 | LOW | Unfiltered User Input | โ Remediated | March 2026 |
| AD10 | LOW | Weak Content Security Policy | โ Remediated โ 1 item accepted (Trusted Types) | March 2026 |
| AD11 | LOW | Admin Panel Accessible | โ Remediated | March 2026 |
Outstanding Actions
AD6 โ Manual Lockout End-to-End Verification (Required)
The django-axes configuration is in place and has been code-reviewed. The lockout behaviour should be verified with a manual end-to-end test in the staging environment before being formally closed.
Pre-conditions for a valid test:
- Must be run against the staging (or production) environment, not localhost/dev.
AXES_IP_WHITELISTincludes127.0.0.1andlocalhostwithAXES_NEVER_LOCKOUT_WHITELIST = True, so axes intentionally never locks out local IP addresses. Testing from localhost will produce no lockout and no email.- Requests must be spaced โฅ 15 seconds apart.
- The nginx
authrate-limit zone is5r/m burst=3 nodelayon all/(accounts|login|...)paths. Rapid successive submissions are rejected with HTTP 429 by nginx before Django sees them โ axes never records the failure and no email is sent. Space each attempt by at least 15 seconds so nginx passes each request through.
Test procedure:
- Use a test account with a known password against the staging URL.
- Submit a failed login attempt (wrong password). Wait 15 seconds.
- Repeat step 2 a total of 5 times.
- On the 6th attempt: confirm the response is the
403_lockout.htmllockout page (HTTP 403 from Django, not HTTP 429 from nginx โ if you see 429 you are submitting too fast). - Confirm the lockout notification email arrives in the test account's inbox.
- Repeat from a different IP/VPN using the same email โ confirm the account remains locked (username-based lock is IP-independent).
- Wait for the 1-hour cooldown (or clear via
axes.utils.reset_request()in a shell) and confirm normal login is restored.
| Test Date | Tester | Result | Notes |
|---|---|---|---|
AD10 โ Accepted Risk (Remaining)
- ~~Add
base-uri: 'self'to CSP directives.~~ โ Done 25/03/2026 - Evaluate
require-trusted-types-for 'script'adoption โ accepted as a long-term item; requires DOM manipulation refactor.
Approvals
| Role | Name | Date | Signature |
|---|---|---|---|
| CTO | 25/03/2026 | ||
| SIRO/DPO |
This document forms part of the CheckTick Security Remediation Evidence Chain and should be retained for a minimum of 3 years in accordance with our Data Retention Policy.