# Security Policy

## Supported Versions

| Version | Supported |
|---|---|
| 1.0.x | Yes |

## Reporting a Vulnerability

Please **do not** report security vulnerabilities via GitHub issues.

Email: security@trilobita.co.uk

Include as much detail as possible:
- Plugin version affected
- WordPress version
- PHP version
- Steps to reproduce
- Potential impact

We aim to acknowledge reports within 48 hours and provide a fix or mitigation within 14 days.

---

## Security Measures

### Authentication & Authorisation

- The Post Pruner admin page requires the `edit_others_posts` capability (editors and administrators)
- Capability is checked both at menu registration and inside `render_page()` as defence-in-depth
- All row actions verify `edit_others_posts` capability **and** per-post `edit_post` capability before acting
- The `tripp_list_table_class` filter return value is validated with `is_subclass_of()` before instantiation to prevent class injection

### Nonce Verification

- Every row action URL contains a nonce bound to the specific post ID: `tripp_row_action_{post_id}`
- Nonces are verified with `check_admin_referer()` before any action is taken
- Admin notices read from `$_GET['tripp_message']` after a redirect — this is display-only and contains no nonce, but the value is passed through `sanitize_key()` and matched against a fixed allowlist before output

### Input Sanitisation

- `$_GET['tripp_action']` — sanitized with `sanitize_key()` and validated against an allowlist
- `$_GET['post_id']` — cast with `absint()` and checked to be `>= 1`
- `$_GET['tripp_age']` — sanitized with `sanitize_key()` and validated against the fixed set of valid age keys
- `$_GET['orderby']` / `$_GET['order']` — sanitized with `sanitize_key()` and mapped to fixed WP_Query values
- Screen Options `tripp_posts_per_page` — sanitized with `absint()` and clamped to 1–200 to prevent excessively large queries

### Output Escaping

- All HTML output is escaped: `esc_html()`, `esc_attr()`, `esc_url()`
- Admin notice output uses `esc_html()` on all dynamic values
- The "Never updated" badge title attribute is a static i18n string escaped with `esc_attr__()`

### SQL

- No raw SQL queries. All data retrieval uses WordPress APIs: `WP_Query`, `get_posts()`, `get_post()`, `get_userdata()`, `get_the_category()`

### Post Actions

- Actions are only permitted on posts with `publish` status (the plugin only shows published posts)
- Post type is validated against the `tripp_allowed_post_types` allowlist before any action
- All actions move posts to Trash — permanent deletion is never performed

### JavaScript

- No external scripts loaded
- User-facing confirmation dialogs use `window.confirm()` only — no HTML injection possible
- Translatable strings are passed via `wp_localize_script()` (JSON-encoded by WordPress)

### File Security

- All PHP files begin with `defined('ABSPATH') || exit;`
- `uninstall.php` begins with `defined('WP_UNINSTALL_PLUGIN') || exit;`
- Uninstall removes both age-count transients and `tripp_age_mode` user meta from all users, leaving no data behind
- No file uploads, no file writes, no `eval()`, no `unserialize()`

---

## Known Limitations

- The `tripp_message` query parameter used for admin notices is not nonce-protected. It is sanitized and matched against a fixed allowlist, so the attack surface is limited to displaying one of a small set of fixed strings. No user-supplied content is ever rendered.
- The `tripp_allowed_post_types` and `tripp_list_table_class` filters are intended for the Pro plugin only. They run inside a page callback that requires `edit_others_posts`, but any installed plugin could hook them. The class filter is mitigated by `is_subclass_of()` validation; the post types filter is validated against `in_array()` with strict comparison.
