Commit 16c0617b authored by ThinhNC's avatar ThinhNC

Merge branch 'refactor/audit-fixes-and-dynamic-rbac-permissions' into 'develop'

Refactor/audit fixes and dynamic rbac permissions

See merge request !9
parents 3d0ed01b 998ad180
This diff is collapsed.
This diff is collapsed.
# Client-Side and Browser Hunting
#### 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.
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.
## 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.
```
## DOM-based injection 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.
**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`.
## Client-side trust and messaging attack classes (subagent_type: `general`)
**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-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.
**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).
## UI-redress and navigation attack classes (subagent_type: `general`)
**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.
**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.
**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.
## Prototype pollution attack classes (subagent_type: `general`)
**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.
## 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.
## 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.
# Vulnerability Hunting
### Phase 2: Hunt for vulnerabilities
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.
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.
**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.
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)
#### Hunting methodology — include in every Phase 2 agent prompt
Tell each agent to think like an attacker, not a code reviewer:
```
## 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.
```
# Memory Safety, Binary, and Kernel Hunting
#### 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.
Pick the relevant classes based on Phase 1. Split per subsystem for large targets.
## 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.
```
## Memory-safety 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.
**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.
**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.
**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.
## Kernel & privileged-interface 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.
## 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.
## 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.
# Reconnaissance
### Phase 1: Understand the application
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:
**Agent 1a: Overview, tech stack, and comparable baseline**
```
Explore the codebase at <path>. 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.
```
**Agent 1b: Trust boundaries and access control**
```
Explore the codebase at <path>. 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: Input surface inventory**
```
Explore the codebase at <path>. 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.
```
Collect all three agents' outputs and synthesize them into `<output-dir>/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
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.
This diff is collapsed.
# Validation, Reporting, and Verification
### Phase 3: Validate findings
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.
For findings from the same attack surface, batch them into one validation agent. Launch validation agents in parallel where they cover independent areas.
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):
**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.
Tell each validation agent:
```
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]"
```
**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.
### Phase 4: Report
Write the report to the output directory established in Setup.
**Output files:**
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)
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
Keep it short. If the report is longer than the codebase deserves, you're padding.
### Phase 5: Structured output and schema check
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 `<output-dir>/findings.json`.
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
**Before writing `findings.json`:**
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 <skill-dir>/validate-findings.cjs <output-dir>/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.
### Phase 6: Independent verification
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`.
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:
```
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.
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
2. Verify the root_cause statement by reading the cited file and confirming the described defect exists.
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?
4. Verify conditions are complete — are there prerequisites the finding missed?
5. Check the remediation code_changes — would the fix actually prevent the attack without breaking normal functionality?
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.
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
```
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
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.
This is the final quality gate. Do not skip it.
This diff is collapsed.
{
"$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": {
"oneOf": [
{
"type": "object",
"description": "Confirmed vulnerability — provide the complete, independently verified report.",
"properties": {
"verdict": {
"type": "string",
"const": "confirmed"
},
"title": {
"type": "string",
"description": "A concise, standard title for the vulnerability."
},
"description": {
"type": "string",
"description": "Comprehensive explanation of the vulnerability. Include any reproduction details (proof-of-concept input, configuration, observed output or crash) here."
},
"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."
},
"intended_behavior": {
"type": "string",
"description": "What was the developer trying to build? Explain the intended, non-vulnerable business logic."
},
"trace": {
"type": "array",
"minItems": 2,
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["entrypoint", "propagation", "sink"]
},
"file": {
"type": "string",
"description": "Exact file path relative to repository root."
},
"line": {
"type": "integer"
},
"scope": {
"type": "string",
"description": "Bare function or method name. No parentheses, no arguments."
},
"description": {
"type": "string",
"description": "Factual description of the state change or data movement."
}
},
"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)."
},
"conditions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": [
"authentication_level",
"authorization_role",
"user_interaction",
"system_configuration",
"network_routing",
"environmental_dependency",
"data_state",
"timing_dependency",
"third_party_dependency"
]
},
"description": {
"type": "string"
}
},
"required": ["kind", "description"],
"additionalProperties": false
},
"description": "Factual prerequisites for exploitation. Empty array if exploitable by default."
},
"execution": {
"type": "object",
"properties": {
"attacker_perspective": {
"type": "string",
"description": "Who is the attacker and their starting point."
},
"payloads": {
"type": "array",
"items": {
"type": "string"
},
"description": "Specific malicious inputs, HTTP requests, or scripts."
},
"instructions": {
"type": "array",
"items": {
"type": "string"
},
"description": "Linear array of all attacker actions from setup through exploitation."
},
"expected_result": {
"type": "string",
"description": "Observable outcome confirming successful exploitation."
}
},
"required": [
"attacker_perspective",
"payloads",
"instructions",
"expected_result"
],
"additionalProperties": false
},
"remediation": {
"type": "object",
"properties": {
"strategy": {
"type": "string",
"description": "High-level explanation of the fix."
},
"code_changes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"file_name": {
"type": "string"
},
"fixed_code": {
"type": "string"
}
},
"required": ["file_name", "fixed_code"],
"additionalProperties": false
}
}
},
"required": ["strategy"],
"additionalProperties": false
},
"severity": {
"type": "object",
"properties": {
"likelihood": {
"type": "object",
"properties": {
"score": {
"type": "string",
"enum": [
"informational",
"low",
"medium",
"high",
"critical"
]
},
"reason": {
"type": "string"
}
},
"required": ["score", "reason"],
"additionalProperties": false
},
"impact": {
"type": "object",
"properties": {
"score": {
"type": "string",
"enum": [
"informational",
"low",
"medium",
"high",
"critical"
]
},
"reason": {
"type": "string"
}
},
"required": ["score", "reason"],
"additionalProperties": false
},
"overall_severity": {
"type": "string",
"enum": ["informational", "low", "medium", "high", "critical"]
}
},
"required": ["likelihood", "impact", "overall_severity"],
"additionalProperties": false
},
"confidence": {
"type": "object",
"properties": {
"score": {
"type": "string",
"enum": ["low", "medium", "high"]
},
"reason": {
"type": "string",
"description": "Why you scored the confidence this way. Mention any missing files, complex routing, or ambiguous data flows."
}
},
"required": ["score", "reason"],
"additionalProperties": false
}
},
"required": [
"verdict",
"title",
"description",
"root_cause",
"intended_behavior",
"trace",
"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.",
"properties": {
"verdict": {
"type": "string",
"const": "rejected"
},
"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)."
}
},
"required": ["verdict", "reason"],
"additionalProperties": false
}
]
}
}
#!/usr/bin/env node
/**
* Validates findings.json against report-schema.json.
* Usage: node validate-findings.cjs <path-to-findings.json>
*
* 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.
*/
const fs = require("fs");
const path = require("path");
const file = process.argv[2];
if (!file) {
console.error("Usage: node validate-findings.cjs <path-to-findings.json>");
process.exit(1);
}
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);
}
let findings;
try {
findings = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (e) {
console.error("Failed to parse JSON:", e.message);
process.exit(1);
}
if (!Array.isArray(findings)) {
console.error("findings.json must be an array");
process.exit(1);
}
// --- Generic JSON Schema interpreter (the subset used by report-schema.json) ---
function typeOf(v) {
if (Array.isArray(v)) return "array";
if (v === null) return "null";
return typeof v; // "object" | "string" | "number" | "boolean"
}
// 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;
}
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;
});
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);
}
...@@ -110,14 +110,14 @@ Small, focused changes are easier to review, faster to merge, and safer to deplo ...@@ -110,14 +110,14 @@ Small, focused changes are easier to review, faster to merge, and safer to deplo
~1000 lines changed → Too large. Split it. ~1000 lines changed → Too large. Split it.
``` ```
**Watch file size, not just diff size.** A small diff can still push a file past a healthy boundary — around 1000 *total* lines in a single file (distinct from the ~1000 *changed*-lines threshold above) is a common inspection signal, not a hard cap. When a change materially grows an already-large file, ask whether to extract helpers, subcomponents, or modules *first*, before piling more on. Decompose, then add. **Watch file size, not just diff size.** A small diff can still push a file past a healthy boundary — around 1000 _total_ lines in a single file (distinct from the ~1000 _changed_-lines threshold above) is a common inspection signal, not a hard cap. When a change materially grows an already-large file, ask whether to extract helpers, subcomponents, or modules _first_, before piling more on. Decompose, then add.
**What counts as "one change":** A single self-contained modification that addresses one thing, includes related tests, and keeps the system functional after submission. One part of a feature — not the whole feature. **What counts as "one change":** A single self-contained modification that addresses one thing, includes related tests, and keeps the system functional after submission. One part of a feature — not the whole feature.
**Splitting strategies when a change is too large:** **Splitting strategies when a change is too large:**
| Strategy | How | When | | Strategy | How | When |
|----------|-----|------| | ----------------- | ------------------------------------------------------- | ----------------------- |
| **Stack** | Submit a small change, start the next one based on it | Sequential dependencies | | **Stack** | Submit a small change, start the next one based on it | Sequential dependencies |
| **By file group** | Separate changes for groups needing different reviewers | Cross-cutting concerns | | **By file group** | Separate changes for groups needing different reviewers | Cross-cutting concerns |
| **Horizontal** | Create shared code/stubs first, then consumers | Layered architecture | | **Horizontal** | Create shared code/stubs first, then consumers | Layered architecture |
...@@ -179,8 +179,8 @@ For each file changed: ...@@ -179,8 +179,8 @@ For each file changed:
Label every comment with its severity so the author knows what's required vs optional: Label every comment with its severity so the author knows what's required vs optional:
| Prefix | Meaning | Author Action | | Prefix | Meaning | Author Action |
|--------|---------|---------------| | ----------------------------- | ------------------ | ------------------------------------------------------- |
| *(no prefix)* | Required change | Must address before merge | | _(no prefix)_ | Required change | Must address before merge |
| **Critical:** | Blocks merge | Security vulnerability, data loss, broken functionality | | **Critical:** | Blocks merge | Security vulnerability, data loss, broken functionality |
| **Nit:** | Minor, optional | Author may ignore — formatting, style preferences | | **Nit:** | Minor, optional | Author may ignore — formatting, style preferences |
| **Optional:** / **Consider:** | Suggestion | Worth considering but not required | | **Optional:** / **Consider:** | Suggestion | Worth considering but not required |
...@@ -188,7 +188,7 @@ Label every comment with its severity so the author knows what's required vs opt ...@@ -188,7 +188,7 @@ Label every comment with its severity so the author knows what's required vs opt
This prevents authors from treating all feedback as mandatory and wasting time on optional suggestions. This prevents authors from treating all feedback as mandatory and wasting time on optional suggestions.
**Lead with what matters.** Order findings by leverage: correctness and security first, then structural regressions and missed simplifications, then everything else. Don't bury a real issue under cosmetic nits — a few high-conviction comments beat a long list. If you have one structural problem and ten nits, the structural problem *is* the review. **Lead with what matters.** Order findings by leverage: correctness and security first, then structural regressions and missed simplifications, then everything else. Don't bury a real issue under cosmetic nits — a few high-conviction comments beat a long list. If you have one structural problem and ten nits, the structural problem _is_ the review.
### Step 5: Verify the Verification ### Step 5: Verify the Verification
...@@ -222,6 +222,7 @@ Human makes the final call ...@@ -222,6 +222,7 @@ Human makes the final call
This catches issues that a single model might miss — different models have different blind spots. This catches issues that a single model might miss — different models have different blind spots.
**Example prompt for a review agent:** **Example prompt for a review agent:**
``` ```
Review this code change for correctness, security, and adherence to Review this code change for correctness, security, and adherence to
our project conventions. The spec says [X]. The change should [Y]. our project conventions. The spec says [X]. The change should [Y].
...@@ -281,6 +282,7 @@ When reviewing code — whether written by you, another agent, or a human: ...@@ -281,6 +282,7 @@ When reviewing code — whether written by you, another agent, or a human:
Part of code review is dependency review: Part of code review is dependency review:
**Before adding any dependency:** **Before adding any dependency:**
1. Does the existing stack solve this? (Often it does.) 1. Does the existing stack solve this? (Often it does.)
2. How large is the dependency? (Check bundle impact.) 2. How large is the dependency? (Check bundle impact.)
3. Is it actively maintained? (Check last commit, open issues.) 3. Is it actively maintained? (Check last commit, open issues.)
...@@ -293,11 +295,11 @@ Part of code review is dependency review: ...@@ -293,11 +295,11 @@ Part of code review is dependency review:
1. **Read the changelog, not just the version number.** Semver is a promise the maintainer may not have kept — a "patch" can carry a behavioral change. For a major bump, read the migration notes and find what breaks. 1. **Read the changelog, not just the version number.** Semver is a promise the maintainer may not have kept — a "patch" can carry a behavioral change. For a major bump, read the migration notes and find what breaks.
2. **One dependency per change.** Upgrade and merge them individually (or in small related groups). When a bulk bump breaks the build, you've lost which package did it; a single-package change makes the cause obvious and the revert clean. 2. **One dependency per change.** Upgrade and merge them individually (or in small related groups). When a bulk bump breaks the build, you've lost which package did it; a single-package change makes the cause obvious and the revert clean.
3. **Let the tests decide.** The upgrade is verified by a green suite before *and* after, not by "it installed." If coverage around the dependency's behavior is thin, that gap is the real finding — add a test first. 3. **Let the tests decide.** The upgrade is verified by a green suite before _and_ after, not by "it installed." If coverage around the dependency's behavior is thin, that gap is the real finding — add a test first.
4. **Mind the transitive graph.** Most installed packages are ones nobody chose directly. Review the lockfile diff, not just `package.json`; a single direct bump can pull in dozens of indirect changes. 4. **Mind the transitive graph.** Most installed packages are ones nobody chose directly. Review the lockfile diff, not just `package.json`; a single direct bump can pull in dozens of indirect changes.
5. **Keep the lockfile honest.** Commit it, review its diff, and never hand-edit it. The lockfile is the thing that actually pins what ships. 5. **Keep the lockfile honest.** Commit it, review its diff, and never hand-edit it. The lockfile is the thing that actually pins what ships.
For triaging `npm audit` findings and supply-chain risk (typosquatting, compromised maintainers), follow the `security-and-hardening` skill — this section covers the upgrade *workflow*, that one covers the security verdict. For triaging `npm audit` findings and supply-chain risk (typosquatting, compromised maintainers), follow the `security-and-hardening` skill — this section covers the upgrade _workflow_, that one covers the security verdict.
## The Review Checklist ## The Review Checklist
...@@ -305,20 +307,24 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi ...@@ -305,20 +307,24 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi
## Review: [PR/Change title] ## Review: [PR/Change title]
### Context ### Context
- [ ] I understand what this change does and why - [ ] I understand what this change does and why
### Correctness ### Correctness
- [ ] Change matches spec/task requirements - [ ] Change matches spec/task requirements
- [ ] Edge cases handled - [ ] Edge cases handled
- [ ] Error paths handled - [ ] Error paths handled
- [ ] Tests cover the change adequately - [ ] Tests cover the change adequately
### Readability ### Readability
- [ ] Names are clear and consistent - [ ] Names are clear and consistent
- [ ] Logic is straightforward - [ ] Logic is straightforward
- [ ] No unnecessary complexity - [ ] No unnecessary complexity
### Architecture ### Architecture
- [ ] Follows existing patterns - [ ] Follows existing patterns
- [ ] No unnecessary coupling or dependencies - [ ] No unnecessary coupling or dependencies
- [ ] Appropriate abstraction level - [ ] Appropriate abstraction level
...@@ -326,6 +332,7 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi ...@@ -326,6 +332,7 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi
- [ ] No feature logic in shared modules; file stays within a healthy size - [ ] No feature logic in shared modules; file stays within a healthy size
### Security ### Security
- [ ] No secrets in code - [ ] No secrets in code
- [ ] Input validated at boundaries - [ ] Input validated at boundaries
- [ ] No injection vulnerabilities - [ ] No injection vulnerabilities
...@@ -333,19 +340,23 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi ...@@ -333,19 +340,23 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi
- [ ] External data sources treated as untrusted - [ ] External data sources treated as untrusted
### Performance ### Performance
- [ ] No N+1 patterns - [ ] No N+1 patterns
- [ ] No unbounded operations - [ ] No unbounded operations
- [ ] Pagination on list endpoints - [ ] Pagination on list endpoints
### Verification ### Verification
- [ ] Tests pass - [ ] Tests pass
- [ ] Build succeeds - [ ] Build succeeds
- [ ] Manual verification done (if applicable) - [ ] Manual verification done (if applicable)
### Verdict ### Verdict
- [ ] **Approve** — Ready to merge - [ ] **Approve** — Ready to merge
- [ ] **Request changes** — Issues must be addressed - [ ] **Request changes** — Issues must be addressed
``` ```
## See Also ## See Also
- For detailed security review guidance, see `../../references/security-checklist.md` - For detailed security review guidance, see `../../references/security-checklist.md`
...@@ -354,7 +365,7 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi ...@@ -354,7 +365,7 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi
## Common Rationalizations ## Common Rationalizations
| Rationalization | Reality | | Rationalization | Reality |
|---|---| | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| "It works, that's good enough" | Working code that's unreadable, insecure, or architecturally wrong creates debt that compounds. | | "It works, that's good enough" | Working code that's unreadable, insecure, or architecturally wrong creates debt that compounds. |
| "I wrote it, so I know it's correct" | Authors are blind to their own assumptions. Every change benefits from another set of eyes. | | "I wrote it, so I know it's correct" | Authors are blind to their own assumptions. Every change benefits from another set of eyes. |
| "We'll clean it up later" | Later never comes. The review is the quality gate — use it. Require cleanup before merge, not after. | | "We'll clean it up later" | Later never comes. The review is the quality gate — use it. Require cleanup before merge, not after. |
......
This diff is collapsed.
This diff is collapsed.
# Client-Side and Browser Hunting
#### 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.
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.
## 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.
```
## DOM-based injection 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.
**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`.
## Client-side trust and messaging attack classes (subagent_type: `general`)
**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-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.
**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).
## UI-redress and navigation attack classes (subagent_type: `general`)
**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.
**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.
**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.
## Prototype pollution attack classes (subagent_type: `general`)
**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.
## 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.
## 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.
# Vulnerability Hunting
### Phase 2: Hunt for vulnerabilities
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.
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.
**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.
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)
#### Hunting methodology — include in every Phase 2 agent prompt
Tell each agent to think like an attacker, not a code reviewer:
```
## 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.
```
# Memory Safety, Binary, and Kernel Hunting
#### 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.
Pick the relevant classes based on Phase 1. Split per subsystem for large targets.
## 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.
```
## Memory-safety 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.
**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.
**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.
**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.
## Kernel & privileged-interface 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.
## 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.
## 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.
# Reconnaissance
### Phase 1: Understand the application
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:
**Agent 1a: Overview, tech stack, and comparable baseline**
```
Explore the codebase at <path>. 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.
```
**Agent 1b: Trust boundaries and access control**
```
Explore the codebase at <path>. 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: Input surface inventory**
```
Explore the codebase at <path>. 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.
```
Collect all three agents' outputs and synthesize them into `<output-dir>/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
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.
This diff is collapsed.
# Validation, Reporting, and Verification
### Phase 3: Validate findings
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.
For findings from the same attack surface, batch them into one validation agent. Launch validation agents in parallel where they cover independent areas.
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):
**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.
Tell each validation agent:
```
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]"
```
**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.
### Phase 4: Report
Write the report to the output directory established in Setup.
**Output files:**
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)
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
Keep it short. If the report is longer than the codebase deserves, you're padding.
### Phase 5: Structured output and schema check
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 `<output-dir>/findings.json`.
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
**Before writing `findings.json`:**
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 <skill-dir>/validate-findings.cjs <output-dir>/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.
### Phase 6: Independent verification
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`.
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:
```
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.
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
2. Verify the root_cause statement by reading the cited file and confirming the described defect exists.
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?
4. Verify conditions are complete — are there prerequisites the finding missed?
5. Check the remediation code_changes — would the fix actually prevent the attack without breaking normal functionality?
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.
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
```
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
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.
This is the final quality gate. Do not skip it.
This diff is collapsed.
{
"$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": {
"oneOf": [
{
"type": "object",
"description": "Confirmed vulnerability — provide the complete, independently verified report.",
"properties": {
"verdict": {
"type": "string",
"const": "confirmed"
},
"title": {
"type": "string",
"description": "A concise, standard title for the vulnerability."
},
"description": {
"type": "string",
"description": "Comprehensive explanation of the vulnerability. Include any reproduction details (proof-of-concept input, configuration, observed output or crash) here."
},
"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."
},
"intended_behavior": {
"type": "string",
"description": "What was the developer trying to build? Explain the intended, non-vulnerable business logic."
},
"trace": {
"type": "array",
"minItems": 2,
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["entrypoint", "propagation", "sink"]
},
"file": {
"type": "string",
"description": "Exact file path relative to repository root."
},
"line": {
"type": "integer"
},
"scope": {
"type": "string",
"description": "Bare function or method name. No parentheses, no arguments."
},
"description": {
"type": "string",
"description": "Factual description of the state change or data movement."
}
},
"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)."
},
"conditions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": [
"authentication_level",
"authorization_role",
"user_interaction",
"system_configuration",
"network_routing",
"environmental_dependency",
"data_state",
"timing_dependency",
"third_party_dependency"
]
},
"description": {
"type": "string"
}
},
"required": ["kind", "description"],
"additionalProperties": false
},
"description": "Factual prerequisites for exploitation. Empty array if exploitable by default."
},
"execution": {
"type": "object",
"properties": {
"attacker_perspective": {
"type": "string",
"description": "Who is the attacker and their starting point."
},
"payloads": {
"type": "array",
"items": {
"type": "string"
},
"description": "Specific malicious inputs, HTTP requests, or scripts."
},
"instructions": {
"type": "array",
"items": {
"type": "string"
},
"description": "Linear array of all attacker actions from setup through exploitation."
},
"expected_result": {
"type": "string",
"description": "Observable outcome confirming successful exploitation."
}
},
"required": [
"attacker_perspective",
"payloads",
"instructions",
"expected_result"
],
"additionalProperties": false
},
"remediation": {
"type": "object",
"properties": {
"strategy": {
"type": "string",
"description": "High-level explanation of the fix."
},
"code_changes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"file_name": {
"type": "string"
},
"fixed_code": {
"type": "string"
}
},
"required": ["file_name", "fixed_code"],
"additionalProperties": false
}
}
},
"required": ["strategy"],
"additionalProperties": false
},
"severity": {
"type": "object",
"properties": {
"likelihood": {
"type": "object",
"properties": {
"score": {
"type": "string",
"enum": [
"informational",
"low",
"medium",
"high",
"critical"
]
},
"reason": {
"type": "string"
}
},
"required": ["score", "reason"],
"additionalProperties": false
},
"impact": {
"type": "object",
"properties": {
"score": {
"type": "string",
"enum": [
"informational",
"low",
"medium",
"high",
"critical"
]
},
"reason": {
"type": "string"
}
},
"required": ["score", "reason"],
"additionalProperties": false
},
"overall_severity": {
"type": "string",
"enum": ["informational", "low", "medium", "high", "critical"]
}
},
"required": ["likelihood", "impact", "overall_severity"],
"additionalProperties": false
},
"confidence": {
"type": "object",
"properties": {
"score": {
"type": "string",
"enum": ["low", "medium", "high"]
},
"reason": {
"type": "string",
"description": "Why you scored the confidence this way. Mention any missing files, complex routing, or ambiguous data flows."
}
},
"required": ["score", "reason"],
"additionalProperties": false
}
},
"required": [
"verdict",
"title",
"description",
"root_cause",
"intended_behavior",
"trace",
"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.",
"properties": {
"verdict": {
"type": "string",
"const": "rejected"
},
"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)."
}
},
"required": ["verdict", "reason"],
"additionalProperties": false
}
]
}
}
#!/usr/bin/env node
/**
* Validates findings.json against report-schema.json.
* Usage: node validate-findings.cjs <path-to-findings.json>
*
* 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.
*/
const fs = require("fs");
const path = require("path");
const file = process.argv[2];
if (!file) {
console.error("Usage: node validate-findings.cjs <path-to-findings.json>");
process.exit(1);
}
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);
}
let findings;
try {
findings = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (e) {
console.error("Failed to parse JSON:", e.message);
process.exit(1);
}
if (!Array.isArray(findings)) {
console.error("findings.json must be an array");
process.exit(1);
}
// --- Generic JSON Schema interpreter (the subset used by report-schema.json) ---
function typeOf(v) {
if (Array.isArray(v)) return "array";
if (v === null) return "null";
return typeof v; // "object" | "string" | "number" | "boolean"
}
// 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;
}
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;
});
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);
}
This diff is collapsed.
/*
Warnings:
- You are about to drop the `CrawlJobLog` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "CrawlJobLog" DROP CONSTRAINT "CrawlJobLog_job_id_fkey";
-- DropForeignKey
ALTER TABLE "crawl_assets" DROP CONSTRAINT "crawl_assets_crawl_job_id_fkey";
-- DropTable
DROP TABLE "CrawlJobLog";
-- CreateTable
CREATE TABLE "crawl_job_logs" (
"id" UUID NOT NULL,
"job_id" UUID NOT NULL,
"level" "LogLevel" NOT NULL,
"step" TEXT NOT NULL,
"message" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "crawl_job_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "crawl_job_logs_job_id_created_at_idx" ON "crawl_job_logs"("job_id", "created_at");
-- CreateIndex
CREATE INDEX "audit_logs_user_id_created_at_idx" ON "audit_logs"("user_id", "created_at");
-- CreateIndex
CREATE INDEX "audit_logs_action_idx" ON "audit_logs"("action");
-- CreateIndex
CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs"("created_at");
-- CreateIndex
CREATE INDEX "audit_logs_ip_address_idx" ON "audit_logs"("ip_address");
-- CreateIndex
CREATE INDEX "crawl_assets_crawl_job_id_idx" ON "crawl_assets"("crawl_job_id");
-- CreateIndex
CREATE INDEX "crawl_jobs_user_id_created_at_idx" ON "crawl_jobs"("user_id", "created_at");
-- AddForeignKey
ALTER TABLE "crawl_assets" ADD CONSTRAINT "crawl_assets_crawl_job_id_fkey" FOREIGN KEY ("crawl_job_id") REFERENCES "crawl_jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "crawl_job_logs" ADD CONSTRAINT "crawl_job_logs_job_id_fkey" FOREIGN KEY ("job_id") REFERENCES "crawl_jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
...@@ -152,6 +152,7 @@ model CrawlJob { ...@@ -152,6 +152,7 @@ model CrawlJob {
@@index([status]) @@index([status])
@@index([createdAt]) @@index([createdAt])
@@index([userId, status]) @@index([userId, status])
@@index([userId, createdAt])
@@index([scheduleId]) @@index([scheduleId])
@@map("crawl_jobs") @@map("crawl_jobs")
} }
...@@ -206,7 +207,7 @@ model CrawlAsset { ...@@ -206,7 +207,7 @@ model CrawlAsset {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
page CrawlPage? @relation(fields: [pageId], references: [id], onDelete: SetNull) page CrawlPage? @relation(fields: [pageId], references: [id], onDelete: SetNull)
crawlJob CrawlJob? @relation(fields: [crawlJobId], references: [id]) crawlJob CrawlJob? @relation(fields: [crawlJobId], references: [id], onDelete: Cascade)
crawlJobId String? @map("crawl_job_id") @db.Uuid crawlJobId String? @map("crawl_job_id") @db.Uuid
@@index([pageId]) @@index([pageId])
......
...@@ -6,6 +6,12 @@ ...@@ -6,6 +6,12 @@
"sourceType": "github", "sourceType": "github",
"skillPath": "skills/code-review-and-quality/SKILL.md", "skillPath": "skills/code-review-and-quality/SKILL.md",
"computedHash": "6231479cc74c7ed70a5618b81b3f94034d9806c0319a1a23d15f9b94a368c581" "computedHash": "6231479cc74c7ed70a5618b81b3f94034d9806c0319a1a23d15f9b94a368c581"
},
"security-audit": {
"source": "cloudflare/security-audit-skill",
"sourceType": "github",
"skillPath": "skills/security-audit/SKILL.md",
"computedHash": "02a15f2226610f8c9d23732b9f11fe64865f35b1c26cd0560db370f441e9a199"
} }
} }
} }
...@@ -19,22 +19,21 @@ const app = express(); ...@@ -19,22 +19,21 @@ const app = express();
app.set("trust proxy", parseTrustProxy(envConfig.trustProxy)); app.set("trust proxy", parseTrustProxy(envConfig.trustProxy));
app.use( app.use((req, res, next) => {
helmet({ if (req.path.startsWith("/api-docs")) {
contentSecurityPolicy: false, // Vô hiệu hóa CSP để Swagger UI load stylesheet bình thường return helmet({ contentSecurityPolicy: false })(req, res, next);
}), }
); return helmet()(req, res, next);
});
app.use( app.use(
cors({ cors({
origin: (origin, callback) => { origin: (origin, callback) => {
if (!origin) return callback(null, true); if (!origin) return callback(null, true);
if ( if (envConfig.cors.allowedOrigins.includes(origin)) {
envConfig.cors.allowedOrigins.includes(origin) ||
envConfig.cors.allowedOrigins.includes("*")
) {
return callback(null, true); return callback(null, true);
} }
return callback(new Error(`Origin ${origin} not allowed by CORS`)); return callback(null, false);
}, },
credentials: true, credentials: true,
maxAge: 86400, maxAge: 86400,
......
...@@ -41,6 +41,26 @@ export const PERMISSIONS = { ...@@ -41,6 +41,26 @@ export const PERMISSIONS = {
EXPORTS_READ_ALL: "exports.read_all", EXPORTS_READ_ALL: "exports.read_all",
EXPORTS_CREATE: "exports.create", EXPORTS_CREATE: "exports.create",
EXPORTS_DOWNLOAD: "exports.download", EXPORTS_DOWNLOAD: "exports.download",
EXPORTS_DELETE: "exports.delete",
// Webhooks
WEBHOOKS_READ: "webhooks.read",
WEBHOOKS_CREATE: "webhooks.create",
WEBHOOKS_UPDATE: "webhooks.update",
WEBHOOKS_DELETE: "webhooks.delete",
WEBHOOKS_TEST: "webhooks.test",
// Extraction Templates
EXTRACTION_TEMPLATES_READ: "extraction_templates.read",
EXTRACTION_TEMPLATES_CREATE: "extraction_templates.create",
EXTRACTION_TEMPLATES_UPDATE: "extraction_templates.update",
EXTRACTION_TEMPLATES_DELETE: "extraction_templates.delete",
// API Keys
API_KEYS_READ: "api_keys.read",
API_KEYS_CREATE: "api_keys.create",
API_KEYS_UPDATE: "api_keys.update",
API_KEYS_DELETE: "api_keys.delete",
// Audit Logs // Audit Logs
AUDIT_LOGS_READ: "audit_logs.read", AUDIT_LOGS_READ: "audit_logs.read",
...@@ -305,6 +325,124 @@ export const SYSTEM_PERMISSIONS_CATALOG: PermissionDefinition[] = [ ...@@ -305,6 +325,124 @@ export const SYSTEM_PERMISSIONS_CATALOG: PermissionDefinition[] = [
action: "download", action: "download",
isSystem: true, isSystem: true,
}, },
{
name: "Delete Export",
slug: PERMISSIONS.EXPORTS_DELETE,
description: "Xóa tệp trích xuất dữ liệu",
resource: "exports",
action: "delete",
isSystem: true,
},
// Webhooks
{
name: "View Webhooks",
slug: PERMISSIONS.WEBHOOKS_READ,
description: "Xem cấu hình và nhật ký gửi webhook",
resource: "webhooks",
action: "read",
isSystem: true,
},
{
name: "Create Webhook",
slug: PERMISSIONS.WEBHOOKS_CREATE,
description: "Tạo cấu hình webhook mới",
resource: "webhooks",
action: "create",
isSystem: true,
},
{
name: "Update Webhook",
slug: PERMISSIONS.WEBHOOKS_UPDATE,
description: "Cập nhật cấu hình webhook hoặc gửi lại",
resource: "webhooks",
action: "update",
isSystem: true,
},
{
name: "Delete Webhook",
slug: PERMISSIONS.WEBHOOKS_DELETE,
description: "Xóa cấu hình webhook",
resource: "webhooks",
action: "delete",
isSystem: true,
},
{
name: "Test Webhook",
slug: PERMISSIONS.WEBHOOKS_TEST,
description: "Gửi kiểm thử ping webhook",
resource: "webhooks",
action: "test",
isSystem: true,
},
// Extraction Templates
{
name: "View Extraction Templates",
slug: PERMISSIONS.EXTRACTION_TEMPLATES_READ,
description: "Xem mẫu bóc tách dữ liệu",
resource: "extraction_templates",
action: "read",
isSystem: true,
},
{
name: "Create Extraction Template",
slug: PERMISSIONS.EXTRACTION_TEMPLATES_CREATE,
description: "Tạo mẫu bóc tách dữ liệu mới",
resource: "extraction_templates",
action: "create",
isSystem: true,
},
{
name: "Update Extraction Template",
slug: PERMISSIONS.EXTRACTION_TEMPLATES_UPDATE,
description: "Cập nhật mẫu bóc tách dữ liệu",
resource: "extraction_templates",
action: "update",
isSystem: true,
},
{
name: "Delete Extraction Template",
slug: PERMISSIONS.EXTRACTION_TEMPLATES_DELETE,
description: "Xóa mẫu bóc tách dữ liệu",
resource: "extraction_templates",
action: "delete",
isSystem: true,
},
// API Keys
{
name: "View API Keys",
slug: PERMISSIONS.API_KEYS_READ,
description: "Xem danh sách khóa API",
resource: "api_keys",
action: "read",
isSystem: true,
},
{
name: "Create API Key",
slug: PERMISSIONS.API_KEYS_CREATE,
description: "Tạo khóa API mới",
resource: "api_keys",
action: "create",
isSystem: true,
},
{
name: "Update API Key",
slug: PERMISSIONS.API_KEYS_UPDATE,
description: "Kích hoạt hoặc vô hiệu hóa khóa API",
resource: "api_keys",
action: "update",
isSystem: true,
},
{
name: "Delete API Key",
slug: PERMISSIONS.API_KEYS_DELETE,
description: "Thu hồi và xóa khóa API",
resource: "api_keys",
action: "delete",
isSystem: true,
},
// Audit Logs // Audit Logs
{ {
...@@ -370,6 +508,20 @@ export const SYSTEM_ROLE_DEFAULT_PERMISSIONS: Record< ...@@ -370,6 +508,20 @@ export const SYSTEM_ROLE_DEFAULT_PERMISSIONS: Record<
PERMISSIONS.EXPORTS_READ_ALL, PERMISSIONS.EXPORTS_READ_ALL,
PERMISSIONS.EXPORTS_CREATE, PERMISSIONS.EXPORTS_CREATE,
PERMISSIONS.EXPORTS_DOWNLOAD, PERMISSIONS.EXPORTS_DOWNLOAD,
PERMISSIONS.EXPORTS_DELETE,
PERMISSIONS.WEBHOOKS_READ,
PERMISSIONS.WEBHOOKS_CREATE,
PERMISSIONS.WEBHOOKS_UPDATE,
PERMISSIONS.WEBHOOKS_DELETE,
PERMISSIONS.WEBHOOKS_TEST,
PERMISSIONS.EXTRACTION_TEMPLATES_READ,
PERMISSIONS.EXTRACTION_TEMPLATES_CREATE,
PERMISSIONS.EXTRACTION_TEMPLATES_UPDATE,
PERMISSIONS.EXTRACTION_TEMPLATES_DELETE,
PERMISSIONS.API_KEYS_READ,
PERMISSIONS.API_KEYS_CREATE,
PERMISSIONS.API_KEYS_UPDATE,
PERMISSIONS.API_KEYS_DELETE,
PERMISSIONS.AUDIT_LOGS_READ, PERMISSIONS.AUDIT_LOGS_READ,
PERMISSIONS.DASHBOARD_READ, PERMISSIONS.DASHBOARD_READ,
PERMISSIONS.DASHBOARD_READ_ALL, PERMISSIONS.DASHBOARD_READ_ALL,
...@@ -379,6 +531,7 @@ export const SYSTEM_ROLE_DEFAULT_PERMISSIONS: Record< ...@@ -379,6 +531,7 @@ export const SYSTEM_ROLE_DEFAULT_PERMISSIONS: Record<
PERMISSIONS.CRAWL_JOBS_READ, PERMISSIONS.CRAWL_JOBS_READ,
PERMISSIONS.CRAWL_JOBS_CANCEL, PERMISSIONS.CRAWL_JOBS_CANCEL,
PERMISSIONS.CRAWL_JOBS_RETRY, PERMISSIONS.CRAWL_JOBS_RETRY,
PERMISSIONS.CRAWL_JOBS_DELETE,
PERMISSIONS.CRAWL_SCHEDULES_CREATE, PERMISSIONS.CRAWL_SCHEDULES_CREATE,
PERMISSIONS.CRAWL_SCHEDULES_READ, PERMISSIONS.CRAWL_SCHEDULES_READ,
PERMISSIONS.CRAWL_SCHEDULES_UPDATE, PERMISSIONS.CRAWL_SCHEDULES_UPDATE,
...@@ -387,12 +540,30 @@ export const SYSTEM_ROLE_DEFAULT_PERMISSIONS: Record< ...@@ -387,12 +540,30 @@ export const SYSTEM_ROLE_DEFAULT_PERMISSIONS: Record<
PERMISSIONS.EXPORTS_READ, PERMISSIONS.EXPORTS_READ,
PERMISSIONS.EXPORTS_CREATE, PERMISSIONS.EXPORTS_CREATE,
PERMISSIONS.EXPORTS_DOWNLOAD, PERMISSIONS.EXPORTS_DOWNLOAD,
PERMISSIONS.EXPORTS_DELETE,
PERMISSIONS.DASHBOARD_READ, PERMISSIONS.DASHBOARD_READ,
PERMISSIONS.WEBHOOKS_READ,
PERMISSIONS.WEBHOOKS_CREATE,
PERMISSIONS.WEBHOOKS_UPDATE,
PERMISSIONS.WEBHOOKS_DELETE,
PERMISSIONS.WEBHOOKS_TEST,
PERMISSIONS.EXTRACTION_TEMPLATES_READ,
PERMISSIONS.EXTRACTION_TEMPLATES_CREATE,
PERMISSIONS.EXTRACTION_TEMPLATES_UPDATE,
PERMISSIONS.EXTRACTION_TEMPLATES_DELETE,
PERMISSIONS.API_KEYS_READ,
PERMISSIONS.API_KEYS_CREATE,
PERMISSIONS.API_KEYS_UPDATE,
PERMISSIONS.API_KEYS_DELETE,
], ],
[SYSTEM_ROLE_SLUGS.VIEWER]: [ [SYSTEM_ROLE_SLUGS.VIEWER]: [
PERMISSIONS.CRAWL_JOBS_READ, PERMISSIONS.CRAWL_JOBS_READ,
PERMISSIONS.CRAWL_SCHEDULES_READ, PERMISSIONS.CRAWL_SCHEDULES_READ,
PERMISSIONS.EXPORTS_READ, PERMISSIONS.EXPORTS_READ,
PERMISSIONS.EXPORTS_DOWNLOAD,
PERMISSIONS.DASHBOARD_READ, PERMISSIONS.DASHBOARD_READ,
PERMISSIONS.WEBHOOKS_READ,
PERMISSIONS.EXTRACTION_TEMPLATES_READ,
PERMISSIONS.API_KEYS_READ,
], ],
}; };
...@@ -31,6 +31,7 @@ export const ERROR_CODE = { ...@@ -31,6 +31,7 @@ export const ERROR_CODE = {
PRIVILEGE_ESCALATION_DENIED: "PRIVILEGE_ESCALATION_DENIED", PRIVILEGE_ESCALATION_DENIED: "PRIVILEGE_ESCALATION_DENIED",
SYSTEM_ROLE_PROTECTED: "SYSTEM_ROLE_PROTECTED", SYSTEM_ROLE_PROTECTED: "SYSTEM_ROLE_PROTECTED",
CANNOT_REMOVE_LAST_SUPER_ADMIN: "CANNOT_REMOVE_LAST_SUPER_ADMIN", CANNOT_REMOVE_LAST_SUPER_ADMIN: "CANNOT_REMOVE_LAST_SUPER_ADMIN",
RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED",
} as const; } as const;
export type ErrorCode = keyof typeof ERROR_CODE; export type ErrorCode = keyof typeof ERROR_CODE;
...@@ -14,6 +14,8 @@ import { ...@@ -14,6 +14,8 @@ import {
DATA_QUALITY_MIN_SCORE, DATA_QUALITY_MIN_SCORE,
DATA_CONTRACT_HASH_ALGORITHM, DATA_CONTRACT_HASH_ALGORITHM,
} from "../constants/data-contract.constant"; } from "../constants/data-contract.constant";
import { ASSET_TYPE } from "../constants/asset-type.constant";
import { CRAWL_PAGE_STATUS } from "../constants/crawl-page-status.constant";
/** /**
* Normalize một URL để phục vụ deduplicate và so sánh. * Normalize một URL để phục vụ deduplicate và so sánh.
...@@ -321,7 +323,7 @@ export function transformImages(assets: CrawlAsset[]): ImageRecord[] { ...@@ -321,7 +323,7 @@ export function transformImages(assets: CrawlAsset[]): ImageRecord[] {
const seenUrls = new Set<string>(); const seenUrls = new Set<string>();
return assets return assets
.filter((a) => { .filter((a) => {
if (a.assetType !== "IMAGE") return false; if (a.assetType !== ASSET_TYPE.IMAGE) return false;
if (seenUrls.has(a.url)) return false; if (seenUrls.has(a.url)) return false;
seenUrls.add(a.url); seenUrls.add(a.url);
return true; return true;
...@@ -366,7 +368,7 @@ export function transformPageToRecord( ...@@ -366,7 +368,7 @@ export function transformPageToRecord(
const { page, assets, tables = [], jobDomain, seenContentHashes } = options; const { page, assets, tables = [], jobDomain, seenContentHashes } = options;
const normalizedUrl = page.normalizedUrl || normalizeUrl(page.url); const normalizedUrl = page.normalizedUrl || normalizeUrl(page.url);
const isSuccess = page.status === "SUCCESS"; const isSuccess = page.status === CRAWL_PAGE_STATUS.SUCCESS;
// Clean text từ markdownContent // Clean text từ markdownContent
const rawMarkdown = page.markdownContent ?? null; const rawMarkdown = page.markdownContent ?? null;
......
This diff is collapsed.
This diff is collapsed.
...@@ -695,6 +695,122 @@ const rawSchemas = { ...@@ -695,6 +695,122 @@ const rawSchemas = {
}, },
}, },
}, },
UpdateWebhookConfigRequest: {
type: "object",
properties: {
url: {
type: "string",
format: "uri",
example: "https://example.com/webhook",
},
secret: {
type: "string",
minLength: 16,
maxLength: 128,
example: "new_webhook_secret_key_123456",
},
events: {
type: "array",
items: { type: "string", enum: ["job.completed", "job.failed"] },
example: ["job.completed"],
},
isActive: { type: "boolean", example: true },
},
},
ExtractionTemplateField: {
type: "object",
required: ["name", "selector", "attr", "required"],
properties: {
name: { type: "string", example: "title" },
selector: { type: "string", example: "h1.product-title" },
attr: { type: "string", example: "innerText" },
required: { type: "boolean", example: true },
},
},
ExtractionTemplate: {
type: "object",
properties: {
id: { type: "string", format: "uuid" },
userId: { type: "string", format: "uuid" },
name: { type: "string", example: "E-Commerce Product Extractor" },
domain: { type: "string", example: "example.com" },
fields: {
type: "array",
items: { $ref: "#/components/schemas/ExtractionTemplateField" },
},
createdAt: { type: "string", format: "date-time" },
updatedAt: { type: "string", format: "date-time" },
},
},
CreateExtractionTemplateRequest: {
type: "object",
required: ["name", "domain", "fields"],
properties: {
name: { type: "string", example: "E-Commerce Product Extractor" },
domain: { type: "string", example: "example.com" },
fields: {
type: "array",
items: { $ref: "#/components/schemas/ExtractionTemplateField" },
},
},
},
UpdateExtractionTemplateRequest: {
type: "object",
properties: {
name: { type: "string", example: "Updated Template Name" },
fields: {
type: "array",
items: { $ref: "#/components/schemas/ExtractionTemplateField" },
},
},
},
CrawlJobLog: {
type: "object",
properties: {
id: { type: "string", format: "uuid" },
jobId: { type: "string", format: "uuid" },
level: { type: "string", enum: ["INFO", "WARN", "ERROR"] },
step: { type: "string", example: "FETCH_PAGE" },
message: { type: "string", example: "Successfully fetched page 1" },
createdAt: { type: "string", format: "date-time" },
},
},
DashboardStats: {
type: "object",
properties: {
jobs: {
type: "object",
properties: {
total: { type: "integer", example: 42 },
completed: { type: "integer", example: 35 },
failed: { type: "integer", example: 3 },
running: { type: "integer", example: 2 },
pending: { type: "integer", example: 2 },
},
},
pages: {
type: "object",
properties: {
total: { type: "integer", example: 1250 },
successful: { type: "integer", example: 1200 },
failed: { type: "integer", example: 50 },
},
},
schedules: {
type: "object",
properties: {
total: { type: "integer", example: 5 },
active: { type: "integer", example: 4 },
},
},
exports: {
type: "object",
properties: {
total: { type: "integer", example: 18 },
},
},
},
},
}; };
const outputFile = "./src/docs/swagger.json"; const outputFile = "./src/docs/swagger.json";
......
...@@ -32,11 +32,60 @@ export function errorMiddleware( ...@@ -32,11 +32,60 @@ export function errorMiddleware(
return; return;
} }
// Handle Prisma Known Request Errors
const prismaError = error as { code?: string; meta?: { target?: string[] } };
if (
prismaError.code &&
typeof prismaError.code === "string" &&
prismaError.code.startsWith("P")
) {
switch (prismaError.code) {
case "P2002": {
const target = Array.isArray(prismaError.meta?.target)
? prismaError.meta.target.join(", ")
: "field";
res.status(409).json({
success: false,
message: `A record with this ${target} already exists.`,
code: ERROR_CODE.DUPLICATE_ENTRY,
});
return;
}
case "P2023": {
res.status(400).json({
success: false,
message: "Invalid input format or malformed identifier.",
code: ERROR_CODE.VALIDATION_ERROR,
});
return;
}
case "P2025": {
res.status(404).json({
success: false,
message: "Requested record not found.",
code: ERROR_CODE.NOT_FOUND,
});
return;
}
case "P2003": {
res.status(400).json({
success: false,
message:
"Referenced record does not exist or relation constraint failed.",
code: ERROR_CODE.VALIDATION_ERROR,
});
return;
}
default:
break;
}
}
console.error("[Unhandled Error]", error); console.error("[Unhandled Error]", error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: "Internal server error", message: "Internal server error",
code: "INTERNAL_SERVER_ERROR", code: ERROR_CODE.INTERNAL_SERVER_ERROR,
}); });
} }
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { PermissionSlug } from "../common/constants/permission.constant"; import {
PermissionSlug,
SYSTEM_ROLE_DEFAULT_PERMISSIONS,
} from "../common/constants/permission.constant";
import { SystemRoleSlug } from "../common/constants/system-role.constant";
import { AppError } from "../common/errors/app-error"; import { AppError } from "../common/errors/app-error";
import { ERROR_CODE } from "../common/errors/error-code"; import { ERROR_CODE } from "../common/errors/error-code";
import { PermissionService } from "../modules/permissions/permission.service"; import { PermissionService } from "../modules/permissions/permission.service";
...@@ -12,12 +16,19 @@ async function resolveUserPermissions(req: Request): Promise<string[]> { ...@@ -12,12 +16,19 @@ async function resolveUserPermissions(req: Request): Promise<string[]> {
} }
const permissions = await permissionService.getUserPermissions(req.user.id); const permissions = await permissionService.getUserPermissions(req.user.id);
req.user.permissions = permissions;
if (!req.user.roles) { if (!req.user.roles) {
req.user.roles = await permissionService.getUserRoles(req.user.id); req.user.roles = await permissionService.getUserRoles(req.user.id);
} }
if (permissions.length === 0 && req.user.role) {
const defaultPerms =
SYSTEM_ROLE_DEFAULT_PERMISSIONS[req.user.role as SystemRoleSlug] || [];
req.user.permissions = defaultPerms;
return defaultPerms;
}
req.user.permissions = permissions;
return permissions; return permissions;
} }
......
import rateLimit, { RateLimitRequestHandler } from "express-rate-limit"; import rateLimit, { RateLimitRequestHandler } from "express-rate-limit";
import { envConfig } from "../config/env.config"; import { envConfig } from "../config/env.config";
import { ERROR_CODE } from "../common/errors/error-code";
/** /**
* Global API rate limit per IP, configurable for each environment. * Global API rate limit per IP, configurable for each environment.
...@@ -14,7 +15,7 @@ export const rateLimitMiddleware: RateLimitRequestHandler = rateLimit({ ...@@ -14,7 +15,7 @@ export const rateLimitMiddleware: RateLimitRequestHandler = rateLimit({
message: { message: {
success: false, success: false,
message: "Bạn đã gửi quá nhiều yêu cầu. Vui lòng thử lại sau.", message: "Bạn đã gửi quá nhiều yêu cầu. Vui lòng thử lại sau.",
code: "RATE_LIMIT_EXCEEDED", code: ERROR_CODE.RATE_LIMIT_EXCEEDED,
}, },
}); });
...@@ -26,6 +27,6 @@ export const authRateLimiter: RateLimitRequestHandler = rateLimit({ ...@@ -26,6 +27,6 @@ export const authRateLimiter: RateLimitRequestHandler = rateLimit({
message: { message: {
success: false, success: false,
message: "Quá nhiều yêu cầu xác thực. Vui lòng thử lại sau 1 phút.", message: "Quá nhiều yêu cầu xác thực. Vui lòng thử lại sau 1 phút.",
code: "RATE_LIMIT_EXCEEDED", code: ERROR_CODE.RATE_LIMIT_EXCEEDED,
}, },
}); });
...@@ -7,6 +7,7 @@ export const ALLOWED_AVATAR_MIME_TYPES = [ ...@@ -7,6 +7,7 @@ export const ALLOWED_AVATAR_MIME_TYPES = [
"image/jpeg", "image/jpeg",
"image/png", "image/png",
"image/webp", "image/webp",
"image/gif",
] as const; ] as const;
export const MAX_AVATAR_SIZE_BYTES = 5 * 1024 * 1024; // 5MB export const MAX_AVATAR_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
......
import { Router } from "express"; import { Router } from "express";
import { ApiKeyController } from "./api-key.controller"; import { ApiKeyController } from "./api-key.controller";
import { authMiddleware } from "../../middlewares/auth.middleware"; import { authMiddleware } from "../../middlewares/auth.middleware";
import { validate } from "../../middlewares/validate.middleware"; import { requirePermission } from "../../middlewares/permission.middleware";
import { PERMISSIONS } from "../../common/constants/permission.constant";
import {
validate,
validateParams,
} from "../../middlewares/validate.middleware";
import { import {
createApiKeySchema, createApiKeySchema,
updateApiKeyStatusSchema, updateApiKeyStatusSchema,
apiKeyParamsSchema,
} from "./api-key.validation"; } from "./api-key.validation";
const router = Router(); const router = Router();
...@@ -13,16 +19,30 @@ const controller = new ApiKeyController(); ...@@ -13,16 +19,30 @@ const controller = new ApiKeyController();
router.post( router.post(
"/", "/",
authMiddleware, authMiddleware,
requirePermission(PERMISSIONS.API_KEYS_CREATE),
validate(createApiKeySchema), validate(createApiKeySchema),
controller.create, controller.create,
); );
router.get("/", authMiddleware, controller.list); router.get(
"/",
authMiddleware,
requirePermission(PERMISSIONS.API_KEYS_READ),
controller.list,
);
router.patch( router.patch(
"/:id", "/:id",
authMiddleware, authMiddleware,
requirePermission(PERMISSIONS.API_KEYS_UPDATE),
validateParams(apiKeyParamsSchema),
validate(updateApiKeyStatusSchema), validate(updateApiKeyStatusSchema),
controller.setActive, controller.setActive,
); );
router.delete("/:id", authMiddleware, controller.revoke); router.delete(
"/:id",
authMiddleware,
requirePermission(PERMISSIONS.API_KEYS_DELETE),
validateParams(apiKeyParamsSchema),
controller.revoke,
);
export default router; export default router;
...@@ -24,3 +24,7 @@ export const updateApiKeyStatusSchema = z.object({ ...@@ -24,3 +24,7 @@ export const updateApiKeyStatusSchema = z.object({
invalid_type_error: "API Key status must be a boolean", invalid_type_error: "API Key status must be a boolean",
}), }),
}); });
export const apiKeyParamsSchema = z.object({
id: z.string().uuid("Invalid API key ID format"),
});
...@@ -3,8 +3,8 @@ import { AuditLogController } from "./audit-log.controller"; ...@@ -3,8 +3,8 @@ import { AuditLogController } from "./audit-log.controller";
import { authMiddleware } from "../../middlewares/auth.middleware"; import { authMiddleware } from "../../middlewares/auth.middleware";
import { validateQuery } from "../../middlewares/validate.middleware"; import { validateQuery } from "../../middlewares/validate.middleware";
import { listAuditLogsQuerySchema } from "./audit-log.validation"; import { listAuditLogsQuerySchema } from "./audit-log.validation";
import { requireRole } from "../../middlewares/role.middleware"; import { requirePermission } from "../../middlewares/permission.middleware";
import { ROLES } from "../../common/constants/role.constant"; import { PERMISSIONS } from "../../common/constants/permission.constant";
const router = Router(); const router = Router();
const controller = new AuditLogController(); const controller = new AuditLogController();
...@@ -12,7 +12,7 @@ const controller = new AuditLogController(); ...@@ -12,7 +12,7 @@ const controller = new AuditLogController();
router.get( router.get(
"/", "/",
authMiddleware, authMiddleware,
requireRole(ROLES.ADMIN), requirePermission(PERMISSIONS.AUDIT_LOGS_READ),
validateQuery(listAuditLogsQuerySchema), validateQuery(listAuditLogsQuerySchema),
controller.findAll, controller.findAll,
); );
......
import { prisma } from "../../database/prisma.client"; import { prisma } from "../../database/prisma.client";
import { ROLES } from "../../common/constants/role.constant"; import { ROLES } from "../../common/constants/role.constant";
import { SYSTEM_ROLE_SLUGS } from "../../common/constants/system-role.constant";
export class AuthRepository { export class AuthRepository {
findByEmail(email: string) { findByEmail(email: string) {
...@@ -14,13 +15,14 @@ export class AuthRepository { ...@@ -14,13 +15,14 @@ export class AuthRepository {
}); });
} }
createUser(data: { async createUser(data: {
email: string; email: string;
passwordHash: string; passwordHash: string;
fullName?: string; fullName?: string;
isActive?: boolean; isActive?: boolean;
}) { }) {
return prisma.user.create({ return prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: { data: {
email: data.email, email: data.email,
passwordHash: data.passwordHash, passwordHash: data.passwordHash,
...@@ -29,6 +31,22 @@ export class AuthRepository { ...@@ -29,6 +31,22 @@ export class AuthRepository {
isActive: data.isActive ?? true, isActive: data.isActive ?? true,
}, },
}); });
const defaultRole = await tx.role.findUnique({
where: { slug: SYSTEM_ROLE_SLUGS.CRAWLER_USER },
});
if (defaultRole) {
await tx.userRoleAssignment.create({
data: {
userId: user.id,
roleId: defaultRole.id,
},
});
}
return user;
});
} }
updateUser( updateUser(
...@@ -122,6 +140,11 @@ export class AuthRepository { ...@@ -122,6 +140,11 @@ export class AuthRepository {
where: { userId, isActive: true }, where: { userId, isActive: true },
data: { isActive: false }, data: { isActive: false },
}); });
await tx.webhookConfig.updateMany({
where: { userId, isActive: true },
data: { isActive: false },
});
}); });
} }
} }
...@@ -5,7 +5,10 @@ import { ...@@ -5,7 +5,10 @@ import {
copyRefreshTokenToBody, copyRefreshTokenToBody,
} from "../../middlewares/auth.middleware"; } from "../../middlewares/auth.middleware";
import { authRateLimiter } from "../../middlewares/rate-limit.middleware"; import { authRateLimiter } from "../../middlewares/rate-limit.middleware";
import { validate } from "../../middlewares/validate.middleware"; import {
validate,
validateParams,
} from "../../middlewares/validate.middleware";
import { uploadAvatarMiddleware } from "../../middlewares/upload.middleware"; import { uploadAvatarMiddleware } from "../../middlewares/upload.middleware";
import { import {
loginSchema, loginSchema,
...@@ -20,6 +23,7 @@ import { ...@@ -20,6 +23,7 @@ import {
changePasswordSchema, changePasswordSchema,
requestDeactivationSchema, requestDeactivationSchema,
confirmDeactivationSchema, confirmDeactivationSchema,
avatarFileNameParamsSchema,
} from "./auth.validation"; } from "./auth.validation";
const router = Router(); const router = Router();
...@@ -75,9 +79,13 @@ router.post( ...@@ -75,9 +79,13 @@ router.post(
controller.uploadAvatar(req, res, next); controller.uploadAvatar(req, res, next);
}, },
); );
router.get("/avatar/:fileName", (req, res, next) => { router.get(
"/avatar/:fileName",
validateParams(avatarFileNameParamsSchema),
(req, res, next) => {
controller.getAvatar(req, res, next); controller.getAvatar(req, res, next);
}); },
);
router.post( router.post(
"/change-password", "/change-password",
authMiddleware, authMiddleware,
......
...@@ -43,6 +43,7 @@ export class AuthService { ...@@ -43,6 +43,7 @@ export class AuthService {
private readonly repository = new AuthRepository(); private readonly repository = new AuthRepository();
private readonly mailService = new MailService(); private readonly mailService = new MailService();
private readonly storageService = StorageFactory.getStorageService(); private readonly storageService = StorageFactory.getStorageService();
private readonly crawlJobRepository = new CrawlJobRepository();
private async deliverVerificationEmail( private async deliverVerificationEmail(
user: { id: string; email: string }, user: { id: string; email: string },
...@@ -467,7 +468,7 @@ export class AuthService { ...@@ -467,7 +468,7 @@ export class AuthService {
throw new AppError("User not found", 404, ERROR_CODE.NOT_FOUND); throw new AppError("User not found", 404, ERROR_CODE.NOT_FOUND);
} }
const crawlJobRepo = new CrawlJobRepository(); const crawlJobRepo = this.crawlJobRepository;
const nowZoned = getZonedDateParts(new Date(), DEFAULT_TIMEZONE); const nowZoned = getZonedDateParts(new Date(), DEFAULT_TIMEZONE);
const startOfDay = createUtcDateFromZonedParts( const startOfDay = createUtcDateFromZonedParts(
nowZoned.year, nowZoned.year,
......
...@@ -104,3 +104,14 @@ export const requestDeactivationSchema = z.object({ ...@@ -104,3 +104,14 @@ export const requestDeactivationSchema = z.object({
export const confirmDeactivationSchema = z.object({ export const confirmDeactivationSchema = z.object({
token: z.string().min(1, "Thiếu mã xác nhận vô hiệu hóa."), token: z.string().min(1, "Thiếu mã xác nhận vô hiệu hóa."),
}); });
export const avatarFileNameParamsSchema = z.object({
fileName: z
.string()
.trim()
.regex(
/^[a-zA-Z0-9_.-]+\.(jpg|jpeg|png|webp|gif)$/i,
"Invalid avatar filename format",
)
.refine((name) => !name.includes(".."), "Path traversal is not allowed"),
});
...@@ -14,6 +14,7 @@ import { buildJobRootFilePath } from "../../common/helpers/file.helper"; ...@@ -14,6 +14,7 @@ import { buildJobRootFilePath } from "../../common/helpers/file.helper";
import { normalizeUrl } from "../../common/helpers/data-contract.helper"; import { normalizeUrl } from "../../common/helpers/data-contract.helper";
import { AppError } from "../../common/errors/app-error"; import { AppError } from "../../common/errors/app-error";
import { ERROR_CODE } from "../../common/errors/error-code"; import { ERROR_CODE } from "../../common/errors/error-code";
import { CrawlPageStatus } from "../../common/constants/crawl-page-status.constant";
/** /**
* Minimal page shape required for diff comparison. * Minimal page shape required for diff comparison.
...@@ -26,7 +27,7 @@ type DiffPage = { ...@@ -26,7 +27,7 @@ type DiffPage = {
normalizedUrl: string; normalizedUrl: string;
contentHash: string | null; contentHash: string | null;
wordCount: number; wordCount: number;
status: import("@prisma/client").CrawlPageStatus; status: CrawlPageStatus;
statusCode: number | null; statusCode: number | null;
title: string | null; title: string | null;
crawledAt: Date | null; crawledAt: Date | null;
......
...@@ -41,11 +41,14 @@ export class CrawlExportController { ...@@ -41,11 +41,14 @@ export class CrawlExportController {
const result = await this.service.findAllByUser(req.user.id, page, limit); const result = await this.service.findAllByUser(req.user.id, page, limit);
res.json({ res.json({
success: true, success: true,
data: result.items, data: {
pagination: { items: result.items,
meta: {
total: result.total, total: result.total,
page: result.page, page: result.page,
limit: result.limit, limit: result.limit,
totalPages: Math.ceil(result.total / (result.limit || 1)),
},
}, },
}); });
} catch (error) { } catch (error) {
......
import { Router } from "express"; import { Router } from "express";
import { CrawlExportController } from "./crawl-export.controller"; import { CrawlExportController } from "./crawl-export.controller";
import { authMiddleware } from "../../middlewares/auth.middleware"; import { authMiddleware } from "../../middlewares/auth.middleware";
import { requireRole } from "../../middlewares/role.middleware"; import { requirePermission } from "../../middlewares/permission.middleware";
import { ROLES } from "../../common/constants/role.constant"; import { PERMISSIONS } from "../../common/constants/permission.constant";
import {
validateQuery,
validateParams,
} from "../../middlewares/validate.middleware";
import {
crawlExportQuerySchema,
crawlExportParamsSchema,
} from "./crawl-export.validation";
const router = Router(); const router = Router();
const controller = new CrawlExportController(); const controller = new CrawlExportController();
...@@ -10,19 +18,22 @@ const controller = new CrawlExportController(); ...@@ -10,19 +18,22 @@ const controller = new CrawlExportController();
router.get( router.get(
"/", "/",
authMiddleware, authMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), requirePermission(PERMISSIONS.EXPORTS_READ),
validateQuery(crawlExportQuerySchema),
controller.findAll, controller.findAll,
); );
router.get( router.get(
"/:exportId/download", "/:exportId/download",
authMiddleware, authMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), requirePermission(PERMISSIONS.EXPORTS_DOWNLOAD),
validateParams(crawlExportParamsSchema),
controller.download, controller.download,
); );
router.delete( router.delete(
"/:exportId", "/:exportId",
authMiddleware, authMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER), requirePermission(PERMISSIONS.EXPORTS_DELETE),
validateParams(crawlExportParamsSchema),
controller.delete, controller.delete,
); );
......
import { z } from "zod";
export const crawlExportQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export const crawlExportParamsSchema = z.object({
exportId: z.string().uuid("Invalid export ID format"),
});
import { Router } from "express"; import { Router } from "express";
import { CrawlJobController } from "./crawl-job.controller"; import { CrawlJobController } from "./crawl-job.controller";
import { apiKeyOrAuthMiddleware } from "../../middlewares/api-key.middleware"; import { apiKeyOrAuthMiddleware } from "../../middlewares/api-key.middleware";
import { validate, validateQuery } from "../../middlewares/validate.middleware"; import {
validate,
validateQuery,
validateParams,
} from "../../middlewares/validate.middleware";
import { import {
createCrawlJobSchema, createCrawlJobSchema,
createExportSchema, createExportSchema,
listCrawlJobsQuerySchema, listCrawlJobsQuerySchema,
getAssetsQuerySchema, getAssetsQuerySchema,
jobLogsQuerySchema,
diffQuerySchema,
crawlJobParamsSchema,
} from "./crawl-job.validation"; } from "./crawl-job.validation";
import { crawlPageQuerySchema } from "../crawl-pages/crawl-page.validation"; import { crawlPageQuerySchema } from "../crawl-pages/crawl-page.validation";
import { requireRole } from "../../middlewares/role.middleware"; import { requirePermission } from "../../middlewares/permission.middleware";
import { ROLES } from "../../common/constants/role.constant"; import { PERMISSIONS } from "../../common/constants/permission.constant";
const router = Router(); const router = Router();
const controller = new CrawlJobController(); const controller = new CrawlJobController();
...@@ -18,7 +25,7 @@ const controller = new CrawlJobController(); ...@@ -18,7 +25,7 @@ const controller = new CrawlJobController();
router.post( router.post(
"/", "/",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER), requirePermission(PERMISSIONS.CRAWL_JOBS_CREATE),
validate(createCrawlJobSchema), validate(createCrawlJobSchema),
(req, res, next) => { (req, res, next) => {
controller.create(req, res, next); controller.create(req, res, next);
...@@ -27,70 +34,81 @@ router.post( ...@@ -27,70 +34,81 @@ router.post(
router.get( router.get(
"/", "/",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), requirePermission(PERMISSIONS.CRAWL_JOBS_READ),
validateQuery(listCrawlJobsQuerySchema), validateQuery(listCrawlJobsQuerySchema),
controller.findAll, controller.findAll,
); );
router.get( router.get(
"/:id", "/:id",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), requirePermission(PERMISSIONS.CRAWL_JOBS_READ),
validateParams(crawlJobParamsSchema),
controller.findById, controller.findById,
); );
router.delete( router.delete(
"/:id", "/:id",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER), requirePermission(PERMISSIONS.CRAWL_JOBS_DELETE),
validateParams(crawlJobParamsSchema),
controller.delete, controller.delete,
); );
router.post( router.post(
"/:id/rerun", "/:id/rerun",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER), requirePermission(PERMISSIONS.CRAWL_JOBS_RETRY),
validateParams(crawlJobParamsSchema),
controller.rerun, controller.rerun,
); );
router.get( router.get(
"/:id/logs", "/:id/logs",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), requirePermission(PERMISSIONS.CRAWL_JOBS_READ),
validateParams(crawlJobParamsSchema),
validateQuery(jobLogsQuerySchema),
controller.getLogs, controller.getLogs,
); );
router.get( router.get(
"/:id/events", "/:id/events",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), requirePermission(PERMISSIONS.CRAWL_JOBS_READ),
validateParams(crawlJobParamsSchema),
controller.streamEvents, controller.streamEvents,
); );
router.post( router.post(
"/:id/cancel", "/:id/cancel",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER), requirePermission(PERMISSIONS.CRAWL_JOBS_CANCEL),
validateParams(crawlJobParamsSchema),
controller.cancel, controller.cancel,
); );
router.get( router.get(
"/:id/pages", "/:id/pages",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), requirePermission(PERMISSIONS.CRAWL_JOBS_READ),
validateParams(crawlJobParamsSchema),
validateQuery(crawlPageQuerySchema), validateQuery(crawlPageQuerySchema),
controller.getPages, controller.getPages,
); );
router.get( router.get(
"/:id/pages/preview", "/:id/pages/preview",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), requirePermission(PERMISSIONS.CRAWL_JOBS_READ),
validateParams(crawlJobParamsSchema),
validateQuery(crawlPageQuerySchema), validateQuery(crawlPageQuerySchema),
controller.getPagesPreview, controller.getPagesPreview,
); );
router.get( router.get(
"/:id/exports", "/:id/exports",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), requirePermission(PERMISSIONS.EXPORTS_READ),
validateParams(crawlJobParamsSchema),
controller.getExports, controller.getExports,
); );
router.post( router.post(
"/:id/exports", "/:id/exports",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER), requirePermission(PERMISSIONS.EXPORTS_CREATE),
validateParams(crawlJobParamsSchema),
validate(createExportSchema), validate(createExportSchema),
(req, res, next) => { (req, res, next) => {
controller.createExport(req, res, next); controller.createExport(req, res, next);
...@@ -99,26 +117,32 @@ router.post( ...@@ -99,26 +117,32 @@ router.post(
router.get( router.get(
"/:id/download", "/:id/download",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), requirePermission(PERMISSIONS.EXPORTS_DOWNLOAD),
validateParams(crawlJobParamsSchema),
controller.download, controller.download,
); );
router.get( router.get(
"/:id/assets", "/:id/assets",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), requirePermission(PERMISSIONS.CRAWL_JOBS_READ),
validateParams(crawlJobParamsSchema),
validateQuery(getAssetsQuerySchema), validateQuery(getAssetsQuerySchema),
controller.getAssets, controller.getAssets,
); );
router.get( router.get(
"/:id/diff", "/:id/diff",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), requirePermission(PERMISSIONS.CRAWL_JOBS_READ),
validateParams(crawlJobParamsSchema),
validateQuery(diffQuerySchema),
controller.getDiff, controller.getDiff,
); );
router.get( router.get(
"/:id/diff/download", "/:id/diff/download",
apiKeyOrAuthMiddleware, apiKeyOrAuthMiddleware,
requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), requirePermission(PERMISSIONS.CRAWL_JOBS_READ),
validateParams(crawlJobParamsSchema),
validateQuery(diffQuerySchema),
controller.downloadDiff, controller.downloadDiff,
); );
export default router; export default router;
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment