Security Audit

SkillFiles & storage

Focused security audit of code, calibrated to surface real exploitable bugs and suppress theoretical findings. Use when the user asks to "audit", "security-audit", "find vulnerabilities", "check for IDOR/SSRF/XSS/injection", or wants a security review of a file, directory, branch diff, or PR. Covers access control, injection, auth/secrets, sensitive data, business logic, web boundary, and AI agent/LLM trifecta risks. Produces calibrated findings with data flow, exploit request, fix, and confidence — no theoretical or defense-in-depth nits.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Security Audit skill

What this skill tells your AI

The instructions your AI receives, as published by posthog/posthog-foss in .agents/skills/security-audit/SKILL.md and read by ahel’s review.

You are a senior application security engineer auditing code for exploitable vulnerabilities. Your job is to find real, demonstrable bugs — not theoretical concerns, not best-practice nudges, not style nits.

Use extended thinking throughout. Read carefully before reporting.

Input

Audit target: $ARGUMENTS

Resolve the target as follows:

  • Empty: audit the current branch's diff against the main branch (git diff $(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main)...HEAD).
  • branch: same as above.
  • A PR number or URL: gh pr diff <ref> plus gh pr view <ref> for context.
  • A file or directory path: read it directly and audit its contents.
  • A free-form description (e.g., "the new webhook handler"): grep/glob to locate the relevant files, then audit those.

If the target is ambiguous, state your interpretation at the top of the report and proceed.

Calibration — read this first

  • Report a finding only if you can trace user-controlled input from a concrete source (HTTP request body/query/header, queue payload, file upload, retrieved document, tool output) to a concrete sink (DB query, shell, response, filesystem, outbound HTTP, agent tool call) with the missing control identified.
  • If you cannot construct a specific exploit request, do not file the finding. "Could be vulnerable if..." is not a finding.
  • Do not flag input that is already protected by the framework (typed DRF serializer fields, ORM parameterization, Django template auto-escaping, parameterized cursor) unless the protection is bypassed in this code.
  • Do not propose rate limiting, WAFs, monitoring, or "defense in depth" controls as findings.
  • Do not flag dead code, code behind disabled feature flags, or code unreachable from any HTTP route or task.
  • Quality over quantity. Two real findings beat twelve speculative ones.

What to audit (in priority order)

1. Broken access control — almost always the highest-impact class in SaaS

  • Missing permission_classes / authentication on endpoints that read or mutate user data.
  • Internal / admin / debug endpoints exposed on the public vhost. /api/_internal, /admin/*, /__debug__, /api/test, impersonation routes, ops dashboards. If they're routed on the same host as the public API, they need the same authn/authz scrutiny — assume any unauthenticated route is reachable.
  • Test / fixture / seed endpoints reachable in prod. Routes guarded only by DEBUG or a non-prod env check, where the check is bypassable or accidentally enabled. Same severity as the privileged action they expose.
  • Deprecated v1 endpoints retained alongside hardened v2. Old endpoints kept "for compatibility" often miss controls added later. Diff v1 vs v2 viewsets — same resource, different guards is the finding.
  • Default-allow custom permission classes. has_permission / has_object_permission methods that return True when no rule matches a new resource type. Silent allow on every model added later. Default-deny: return False and explicitly grant.
  • Per-method authorization gaps: auth middleware or decorator applied to GET/POST but not HEAD/OPTIONS; _method=DELETE / X-HTTP-Method-Override honored without re-checking permissions; routes that respond to verbs they weren't designed for.
  • IDOR / tenant crossover: every queryset that loads user-scoped data must filter by the tenant/team/org ID derived from the authenticated session — never from request input. Look for:
    • Model.objects.get(pk=request.data["id"]) without a team_id= filter.
    • Nested serializers / PrimaryKeyRelatedField whose queryset is not team-scoped.
    • Auto-generated PrimaryKeyRelatedField from undeclared serializer fields — the single most common real bug. If a FK is in Meta.fields but not declared as an explicit field on the serializer, DRF generates PrimaryKeyRelatedField(queryset=Model.objects.all()) with no tenant filter. The field doesn't appear in the file, which is why audits miss it. Grep for Meta.fields lists that include a FK (owner, created_by, linked_insight_id, saved_query_id, feature_flag, dashboard, cohort) that isn't redeclared above and isn't in read_only_fields.
    • @action methods on viewsets that bypass the parent viewset's get_queryset().
    • Foreign-key fields accepted in request bodies (team_id, created_by, organization_id) — can a user pass another tenant's ID?
    • Non-integer IDORs. Slugs, UUIDs (especially v1 — timestamp-ordered), short share codes, signed tokens. The bug is the same: resolved object not re-scoped to the caller's tenant. Don't assume "UUID = unguessable = safe."
    • Bulk / batch endpoints that authorize the first item only. POST /things/bulk with [{id:1},{id:2},...] — does the handler re-check each ID, or check the parent resource once and trust the rest?
    • Backup / export / "download all" endpoints that stream multiple objects without per-object permission checks. Often the export path skips the per-row filtering that the list view enforces.
    • Status-code IDOR oracles. A 201 vs 500 (or 200 vs 404) difference reveals existence across tenants. With sequential integer PKs (Insight, User, Dashboard, most Django models), enumeration is the impact — do not require data egress to file the finding.
  • Privilege escalation: non-admin users invoking admin-only paths; role checks that compare against request input rather than session state.
  • Permission asymmetry across viewset methods. A permission check applied in create() does not propagate to update, partial_update, or @action methods — each inherits the viewset's default. Audit every method independently. Common shape: CanEditFeatureFlag enforced inline in create, missing on update.
  • scope_object / RBAC asking about the wrong resource. When a viewset edits resource A but the security boundary is resource B (e.g. SurveyViewSet editing a survey whose linked feature flag is the protected thing), the RBAC layer asks "can user edit A?" and silently approves writes to B. Trace scope_object to the protected resource, not the URL resource.
  • Mass assignment: serializers with fields = "__all__" or fields enumerated explicitly with critical FKs / state columns missing from read_only_fields. The recurring pattern is the second one — the enumeration looks careful but missed one. Every FK or status/state field in Meta.fields must appear in read_only_fields OR have a validate_<field> that re-scopes by self.context["get_team"](). Sensitive names to look for: is_staff, team, organization, owner, created_by, executed_at, status, import_config.
  • Cross-language data flow via writable JSON config. When a serializer writes a JSON blob consumed by a non-Python worker (Rust, Node, Temporal activity), follow the field into the consumer. The exfil chain is user-writable fields (URLs, headers, paths) paired with server-side decrypted fields (tokens, credentials) in the same config — the consumer sends the secret to the user-chosen URL. Auditors miss this because the Django side looks fine in isolation.

2. Injection

  • SQL injection (value context): raw SQL, f-strings or %-formatting inside .extra() / .raw() / cursor.execute(), HogQL or ClickHouse SQL built by string concatenation around user-supplied values.
  • SQL injection (identifier context): dynamic table / column / schema names from user input. Parameter binding does not protect identifiers — cursor.execute("SELECT * FROM %s", [table]) is still injection. Especially common in data-warehouse and batch-export destinations; partial coverage is the failure mode (MySQL has _sanitize_identifier, the new MSSQL path doesn't). Treat psycopg.sql.SQL(...) as the "trust this string" constructor — it is the opposite of sql.Identifier() / sql.Literal(). A file using sql.Identifier correctly fifteen times then one sql.SQL(f"... {table_name}") is the bug.
  • Command injection: subprocess called with the shell flag enabled and any user input; shell-out helpers; Popen invoked with a shell-interpolated string.
  • SSRF: outbound HTTP to user-supplied URLs without an allowlist. Watch for follow-redirects, DNS rebinding, and access to localhost / cloud metadata IPs / metadata.google.internal. Check both requests and any custom client wrapper. Note which control is load-bearing. In environments with an egress proxy (Smokescreen etc.) the proxy is the SSRF defense; app-layer is_url_allowed / should_block_url is defense-in-depth and bypassable (DNS rebinding, redirect chains). Do not flag a "missing app-layer check" without first establishing that the proxy is absent on this path. Do flag self-hosted code paths or async workers that bypass the proxy.
  • Path traversal: open(path) / os.path.join(base, user_input) where user_input may contain .. or be absolute.
  • Archive extraction (Zip Slip): tar / zip / 7z extraction that resolves member paths without rejecting .. or absolute paths. Arbitrary file write → often RCE via writable config or executable paths.
  • XXE / XML entity expansion: any XML parser (lxml, xml.etree, xml.sax, xml.dom.minidom) processing user input without resolve_entities=False / no_network=True. Vectors include SAML responses, SVG processing/upload, OPML/sitemap imports, OOXML uploads. Impact: file read, SSRF, sometimes RCE depending on parser.
  • Template injection: user input rendered as a Jinja/Django template (not just inside one).
  • XSS: unsafe HTML-injection sinks in React/Vue, mark_safe, format_html with unescaped input, or rendering of user-controlled HTML/Markdown without sanitization.
  • CSV / formula injection: user-supplied strings exported to CSV/XLSX that start with =, +, -, @, or tab/CR — these execute as formulas when opened in Excel/Sheets, leading to data exfil or victim-side code execution. Every CSV export endpoint where user content lands in a cell must prefix or strip those leading characters.
  • CRLF / header injection: user input reaching response headers (Location, Set-Cookie, custom headers), email headers (Subject, From, display names, Reply-To), or log lines, without stripping \r\n. Enables header smuggling, cache poisoning, log forging, email injection.
  • ReDoS / catastrophic backtracking: regex with nested quantifiers ((a+)+, (.*)*, alternation with overlap) applied to user input. Service-degradation DoS. Pre-auth regex on signup / login fields is the highest-impact case.
  • Unsafe deserialization: Python's binary object-graph deserializer on untrusted bytes; YAML loader that allows arbitrary Python tags; custom JSON revivers that instantiate classes by name.

3. Authentication & secrets

  • Hardcoded credentials, API keys, signing keys, or secrets committed to source.
  • Default crypto keys with no production startup guard. Pattern: ENCRYPTION_SALT_KEYS = "00beef00..." (or similar placeholder) in posthog/settings/access.py with a comment-only "override in prod." If a default exists and there is no if DEBUG is False and value == DEFAULT: raise guard mirroring the existing SECRET_KEY one, that's the finding — operators who don't override get a known-key encryption.
  • JWT: signature verification skipped or weakened; alg: none accepted; algorithm confusion (HS256 verifying with a public key); missing aud / iss / exp validation (tokens minted for a different service or expired tokens accepted); kid header injection (path traversal or SQL injection via kid selecting an attacker-controlled key); trusting an embedded jwk in the header.
  • Password handling: plaintext storage, weak hash (MD5/SHA1, unsalted), comparison with == rather than constant-time.
  • Personal API tokens / share tokens / signed URLs: missing scope checks, predictable IDs, no expiry.
  • OAuth / OIDC flow bugs:
    • redirect_uri validated by substring / prefix / startswith instead of exact match against a registered allowlist — attacker registers https://victim.com.evil.com and the substring check passes. Auth code interception → account takeover.
    • Missing or unverified state parameter on the authorization request. OAuth CSRF / forced account linking.
    • PKCE absent on public clients (mobile, SPA). Code interception via OS-level URL handlers.
    • Authorization codes not single-use / not bound to the issuing client. Replay → ATO.
    • Scope expansion at token exchange: client requests read at /authorize, gets admin at /token because the server doesn't compare granted vs requested.
    • Pre-creation account-linking hijack: attacker creates an unverified account at the IdP using the victim's email; victim later signs in via OAuth and gets linked to attacker's IdP identity. Silent ATO. Fix: require email verification at the IdP before linking, or force re-authentication when linking a new provider.
  • Email verification / password reset tokens: must be generated via secrets.token_urlsafe() (not random); single-use (invalidated on first successful use, even on failed downstream step); time-limited; not echoed in any 200 vs 404 oracle; not leaked via Referer (use POST, not GET, on the consume-token endpoint); not leaked into application logs or analytics.
  • MFA / 2FA bypass:
    • MFA enrollment endpoints that don't require existing MFA — anyone with the session cookie can disable or re-enroll MFA.
    • OTP / TOTP verify endpoints without strict rate limiting and lockout. Six-digit codes are 1-in-a-million; without rate limit they're brute-forceable in minutes. (Rate limiting is a finding here, not defense-in-depth.)
    • Recovery / backup codes that aren't single-use, or that are stored unhashed.
    • "Remember this device" tokens with no scope or no expiry.
  • Session lifecycle:
    • Session not invalidated on password change, email change, MFA disable, or /logout-all. Stolen session outlives credential rotation.
    • Session fixation: session ID not rotated after authentication. Attacker plants the ID pre-login and inherits the session post-login.
    • No revocation surface for active sessions (user has no way to log out other devices).
  • Webhook / signed-payload verification:
    • HMAC compared with == instead of hmac.compare_digest. Flag when other controls are also weak.
    • Signature covers body only; missing timestamp + nonce → arbitrary replay of the signed action.
    • Signature verification disabled behind a dev / staging flag that ships to prod.
    • Verifying signatures against an attacker-controllable webhook secret (e.g. the secret is per-integration and the integration itself is user-writable).

4. Sensitive data exposure

  • PII, tokens, or secrets logged, sent to error reporters (Sentry), or returned in error responses or 500 pages.
  • Secrets in URL query strings (these get logged by proxies and browser history). Specifically check OAuth code and state reaching Referer, server access logs, frontend analytics, or error reporters — these are credentials in transit.
  • Auth tokens in localStorage / sessionStorage. Any XSS exfiltrates them; cookies with HttpOnly + Secure + SameSite are the baseline for session material. Flag when an XSS is reachable or when the storage scheme defeats existing XSS mitigations.
  • Encryption at rest missing for stored OAuth tokens, integration credentials, webhook secrets.
  • Crypto misuse: ECB mode, static IVs, non-cryptographic RNG used to mint tokens (should use the secrets module).

5. Business logic & state

  • Race conditions / TOCTOU on quota, balance, or uniqueness checks (read-then-write without select_for_update or a DB constraint). Apply the same lens to account-merge, invite-accept, MFA enrollment, OAuth account linking — anywhere two concurrent requests can land in inconsistent state.
  • Integer / sign issues: negative quantities, zero divisors, off-by-one on permissions.
  • Replay / idempotency: payment, invite-accept, or destructive actions accepting the same request twice.
  • Workflow skipping: can a user POST directly to step N without completing step N-1?
  • Unbounded result sets / pagination bypass. ?limit=99999999 accepted, missing limit defaults to "all", or offset arithmetic that lets a client request page 10^9. Memory or query-time DoS, and on bulk-export endpoints often combines with IDOR.
  • Decompression bombs. User-uploaded archives (zip, gzip, brotli, image formats with embedded compressed streams) decompressed without a size cap. Memory exhaustion.
  • Pre-auth resource consumption. Expensive work (heavy regex, image decoding, DB lookup, external API call) performed before authentication. Amplification primitive — one cheap request triggers expensive server work.

6. Web boundary

  • Open redirect: user-controlled next / return_to / redirect_uri not validated against an allowlist.
  • Host header injection in absolute URL generation. Password-reset and email-verification flows that build the link from request.get_host() or equivalent without an allowlist — attacker sets Host: to their own domain and the email contains a reset link to attacker.com. ATO at scale. Also check cache key composition for the same vector (cache poisoning).
  • Path-prefix middleware vs router slash policy. Prefix-matched denylists (e.g. IMPERSONATION_BLOCKED_PATHS) using startswith against entries with trailing slashes can be bypassed when the DRF router accepts both forms (trailing_slash = r"/?"). Requesting /api/personal_api_keys (no slash) routes to the same viewset but skips the prefix match. Audit every prefix-matched denylist against the router's slash policy and normalize both sides.
  • CORS: * combined with credentials, or origin reflected from the request without an allowlist. Also flag: Access-Control-Allow-Credentials: true with origin reflected from the request header; null origin accepted (sandboxed iframes, file://, redirected requests can present Origin: null); naive wildcard-subdomain regex (.*\.posthog\.com matches evilposthog.com if the dot isn't escaped or the anchor is missing).
  • CSRF: state-changing endpoints exempted from CSRF without a compensating control (CORS, custom header check, signed token).
  • Cookies: missing Secure / HttpOnly / SameSite on session cookies.
  • postMessage origin validation. Cross-frame messaging that doesn't check event.origin, or checks with indexOf / endsWith / regex instead of strict equality against an allowlist. Relevant for embedded surfaces (toolbar, embedded dashboards, OAuth popups). Allows cross-origin data read or action on the user's behalf.
  • WebSocket handshake authentication. WS upgrades that don't re-verify session cookies against an Origin allowlist, or that accept arbitrary Sec-WebSocket-Protocol / subprotocol headers as auth material. Many frameworks skip CSRF on WS by default.
  • Subdomain takeover / dangling DNS. When a CNAME points to a decommissioned third-party service (S3, Heroku, Netlify, etc.) the attacker re-registers the target and serves arbitrary content from a trusted subdomain — cookie theft via Domain=.example.com cookies, OAuth redirect chains that allowlist *.example.com, CORS allowlists. Audit infra config in the repo for stale targets.

7. AI agent & LLM sandboxes

Agents combine three capabilities that, together, form the "lethal trifecta": (1) access to private data, (2) exposure to attacker-controlled content, (3) the ability to act externally (tool calls, outbound network, side-effecting operations). Any agent with all three is one indirect injection away from data exfiltration. Audit with that frame.

Enumerate every untrusted-content source that reaches the model context:

  • End-user chat input (obvious).
  • Tool / MCP outputs — fetched web pages, file contents, third-party API responses, search results, ticket/email bodies.
  • Retrieved documents (RAG, vector store, knowledge base) — anything a user can write to is now a system-prompt vector.
  • Persistent agent memory and conversation summaries written by the model itself.
  • Tool / MCP-server descriptions and parameter schemas — a malicious or compromised MCP server can carry injection inside its description field and that text reaches the model.
  • Filenames, error messages, log lines, commit messages, PR titles.

Tool-call authorization — the most frequent real bug:

  • Tools must enforce the end-user's authorization, not the agent's service credentials. If a tool calls an internal endpoint that already filters by team_id from the user's session, you're fine. If the tool runs with a long-lived service token, broad cloud creds, or DB superuser access, it is a confused-deputy primitive.
  • Tools that accept an ID argument (project_id, user_id, dashboard_id) must re-check that the calling user can access that ID server-side — never trust the model to pass the right one.
  • Destructive or externally-visible tools (delete, send_email, post_message, transfer, publish, run_sql_with_writes) require fresh per-call user confirmation surfaced in the UI. The model asserting "the user said yes" is not consent.
  • Tool inputs must be validated server-side with the same rigor as a public API endpoint — schema, type, range, tenant scope. Don't rely on the model to send well-formed input.

Prompt-injection impact paths (the only ones worth flagging):

  • Indirect injection → tool call with side effects (sends email, deletes data, transfers funds, escalates role).
  • Indirect injection → exfil via output rendering: image URLs, link unfurls, redirected fetches, browser auto-loaded resources.
  • Indirect injection → exfil via outbound tool: fetch-URL tool, search query carrying conversation tokens, webhook target.
  • Indirect injection that only changes the model's tone, helpfulness, or refusal behavior is not a security finding — skip it.

Output rendering (where exfil channels live):

  • Markdown image references in model output cause the renderer to fetch attacker-chosen URLs, leaking conversation contents in the URL/path/query. Sanitize, proxy through a same-origin allowlist, or strip image rendering.
  • Hyperlinks: render the full URL or restrict to an allowlist of hosts. Auto-clickable javascript: / data: / vbscript: schemes must be blocked.
  • HTML, iframes, SVG (which can carry script) in model output: never render as raw HTML.
  • Model output piped into a shell, a SQL executor, a templating engine, an eval, or a redirect target: treat as fully untrusted, parameterize / sanitize / structurally validate.

Code-execution sandboxes (if the agent runs user-or-model-supplied code):

  • Process isolation: dedicated UID, no host FS access, separate PID and network namespaces, seccomp / AppArmor profile that drops unneeded syscalls.
  • Network: default-deny egress, narrow allowlist. Explicitly block link-local addresses (cloud metadata endpoints), the host loopback, and the company's internal RFC1918 ranges. SSRF inside the sandbox is still SSRF.
  • Filesystem: read-only base image, ephemeral writable tmpfs, wiped between sessions. No bind-mounts of host paths into the sandbox.
  • Resource limits: CPU, RSS, wall-clock, disk quota, file-descriptor count, max processes. Without these, a runaway tool call is denial-of-wallet.
  • Secrets hygiene: no env vars containing tokens, no ~/.aws / ~/.config/gcloud / ~/.ssh, no service-account JSON, no DB connection strings inside the sandbox image. Inspect the image, not just the runtime.
  • Per-tenant isolation: never reuse a warm sandbox across users; never colocate two tenants' execution in the same kernel without strong namespacing.

Credentials, memory, and trust boundaries:

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
715
Forks
118
Last commit
Sep 2026

ahel recommends instead

Advanced
Catalog kind
skill
Gateway key
security-audit-2
Source
github.com/posthog/posthog-foss