Skip to main content

Open source · MIT · v0.0.2

llm-audit

Static analysis for TypeScript and JavaScript LLM applications.OWASP LLM Top 10 at commit time. A complement to Semgrep'sp/ai-best-practicesfor the TS/JS ecosystem the upstream pack does not cover.

see it work in 5 seconds
shell
brew install semgrep         # one-time
npx llm-audit demo           # all 5 rules vs bundled vulnerable fixtures

No install in your repo, no config file, no flags. Real findings on real intentionally-vulnerable code so you can see what the rules catch before deciding to adopt.

adopt in your project
npm i -D llm-audit
npx llm-audit init           # writes .husky/pre-commit + GH Action
npx llm-audit scan           # run on your own code

Why this exists

AI coding assistants reproduce a small, predictable set of LLM-application bugs. Hardcoded keys. Untrusted input flowing into the system role. Model output piped into eval. The new web-app classics.

The strongest existing rule pack, Semgrep's official p/ai-best-practices, ships 27 rules. Zero of them target JavaScript or TypeScript. Run it on a Next.js + Vercel AI SDK repo and it returns nothing.

llm-audit fills that niche. Five rules in v0, mapped explicitly to OWASP LLM Top 10, distributed as a Semgrep pack with a thin npm CLI on top. Runs at pre-commit and in CI.

Rules in v0

Each rule below shows the shape it catches and the canonical fix. Click through to docs/RULES.md in the repo for the full v1 plan and rule rationale.

ERRORLLM01Prompt InjectionCWE-77CWE-94

untrusted-input-in-system-prompt

User-controlled input flowing into the LLM `system` role across Anthropic, OpenAI, and the Vercel AI SDK.

vulnerabletypescript
import { generateText } from "ai";

export async function vuln(req: any) {
  return generateText({
    model: "claude-opus-4-7" as any,
    system: req.body.persona,                // user controls the system prompt
    prompt: "Tell me about portfolios.",
  });
}
safetypescript
import { generateText } from "ai";
import { z } from "zod";

const Body = z.object({ question: z.string().min(1).max(2000) });

export async function safe(req: any) {
  const { question } = Body.parse(req.body);
  return generateText({
    model: "claude-opus-4-7" as any,
    system: "You are a portfolio assistant. Stay strictly on topic.",
    messages: [{ role: "user", content: question }],
  });
}

Why an AI assistant writes this

Assistants frequently lift the user's customization into the `system` role to make the model follow it. That breaks the authority boundary between developer and user.

Fix

Keep the system prompt static in code. Place user input only in the `user` role. Validate input shape with zod / valibot at the request boundary.

ERRORLLM01Prompt InjectionCWE-77

untrusted-input-concatenated-into-prompt-template

User input concatenated into a single-string prompt with no role boundary between instructions and untrusted text.

vulnerabletypescript
import { generateText } from "ai";

export async function vuln(req: any) {
  return generateText({
    model: "claude-opus-4-7" as any,
    prompt: `Translate the following to French:
${req.body.text}

Output only the translation.`,
  });
}
safetypescript
import { generateText } from "ai";
import { z } from "zod";

const Body = z.object({ text: z.string().min(1).max(4000) });

export async function safe(req: any) {
  const { text } = Body.parse(req.body);
  return generateText({
    model: "claude-opus-4-7" as any,
    system: "Translate the user's text to French. Output only the translation.",
    messages: [{ role: "user", content: text }],
  });
}

Why an AI assistant writes this

Template-literal prompts are the path of least resistance. Every prompt-engineering tutorial reinforces the shape, so assistants reproduce it.

Fix

Use the `messages` API with explicit role boundaries. User input goes only into the `user` role. Validate length and shape with a schema.

ERRORLLM02Insecure Output HandlingCWE-79CWE-94CWE-78

llm-output-insecure-handling

Model output piped into `eval`, `dangerouslySetInnerHTML`, `child_process.exec`, or raw `innerHTML`.

vulnerabletsx
import { generateText } from "ai";

export async function VulnComponent() {
  const r = await generateText({
    model: "claude-opus-4-7" as any,
    prompt: "give me html",
  });
  return <div dangerouslySetInnerHTML={{ __html: r.text }} />;
}
safetsx
import { generateText } from "ai";
import DOMPurify from "isomorphic-dompurify";

export async function safe(el: HTMLElement) {
  const r = await generateText({
    model: "claude-opus-4-7" as any,
    prompt: "give me a paragraph",
  });
  el.innerHTML = DOMPurify.sanitize(r.text);
}

Why an AI assistant writes this

The 'ask the model for code/HTML/a shell command and run it' loop is the canonical demo for agentic AI. Assistants reproduce it without the sanitization layer the demo skipped.

Fix

Validate output against a schema before use. Sanitize before rendering as HTML or markdown. Never pass model output to `eval`, `Function`, or a shell sink.

WARNINGLLM02Insecure Output HandlingCWE-20

model-output-parsed-without-schema

`JSON.parse` on raw model output without a schema validator on the path.

vulnerabletypescript
import { generateText } from "ai";

export async function vuln() {
  const r = await generateText({
    model: "claude-opus-4-7" as any,
    prompt: "respond with JSON: { user, balance }",
  });
  return JSON.parse(r.text);                 // shape is whatever the model emits
}
safetypescript
import { generateText } from "ai";
import { z } from "zod";

const Reply = z.object({
  user: z.string(),
  balance: z.number(),
});

export async function safe() {
  const r = await generateText({
    model: "claude-opus-4-7" as any,
    prompt: "respond with JSON: { user, balance }",
  });
  return Reply.parse(JSON.parse(r.text));
}

Why an AI assistant writes this

Prompts that say 'respond in JSON' are treated as authoritative. JSON.parse is the reflex move; schema validation is extra ceremony demos skip.

Fix

Use `generateObject` (AI SDK) or structured outputs (OpenAI `responseFormat: json_schema`) so the model is constrained. Or run output through a zod / valibot validator before access.

ERRORLLM06Sensitive Information DisclosureCWE-798

hardcoded-llm-api-key

Inline `apiKey:` strings in OpenAI / Anthropic / AI SDK constructors, or `sk-...` shapes in source.

vulnerabletypescript
import OpenAI from "openai";

export const openai = new OpenAI({
  apiKey: "sk-proj-AAAA1111BBBB2222CCCC3333DDDD4444",
});
safetypescript
import OpenAI from "openai";
import { z } from "zod";

const Env = z.object({
  OPENAI_API_KEY: z.string().min(1),
});
const env = Env.parse(process.env);

export const openai = new OpenAI({ apiKey: env.OPENAI_API_KEY });

Why an AI assistant writes this

Quickstart examples show inline keys for brevity. Assistants regress to that shape under 'make it self-contained.'

Fix

Read keys from environment variables, validated at startup with a schema. Use OIDC / workload identity where supported. Run gitleaks in CI as a backstop.

Use it in your repo

Try the rules in 5 seconds

npx llm-audit demo

Runs all 5 rules against the bundled vulnerable fixtures. No project setup, no config. Requires Semgrep on PATH.

One-shot scan of your repo

npx llm-audit scan

Runs the rule pack against the current directory. Useful as a pre-adoption check on a real codebase.

Wire pre-commit + CI

npm i -D llm-audit
npx llm-audit init

Writes a husky pre-commit hook and a GitHub Action workflow. Refuses to overwrite existing files unless you pass --force.

Use with AI coding assistants

The bugs llm-audit catches are mostly produced by AI coding assistants, so the highest-leverage place to invoke it is inside the assistant itself. The package ships a project-local SKILL.md for Claude Code, Cursor, Codex CLI, and any tool that reads the universal skills format. Drop it into your repo with one command:

npx llm-audit init --skill-only

The skill autoloads when the agent edits LLM-integrated code or before commits that touch it, tells it when to invoke npx llm-audit scan --json, and gives it the canonical fix per OWASP entry. If you'd rather not commit a .claude/skills/ file, paste an equivalent instruction into your agent rules (CLAUDE.md, .cursorrules, AGENTS.md); see the README for the snippet.

The JSON envelope is a stable contract (schemaVersion: 1), so agents can rely on the field names without breaking on a future release.

v1 roadmap

Seven more rules planned, each mapped to an OWASP LLM Top 10 entry, with vulnerable + safe fixtures and rationale documented in the repo.

  • · Tool-call handler without an allowlist (LLM08)
  • · Untrusted retrieval context in the system role (LLM01)
  • · System prompt leakage in client bundles (LLM07)
  • · Sensitive context (env, PII) inlined into prompts (LLM06)
  • · Model output rendered as markdown without sanitization (LLM09)
  • · LLM route handler without zod / valibot validation
  • · Streaming response without abort handling

Further reading

  • Building llm-audit. The announcement post, including how it found a real LLM02 bug in this very portfolio.
  • Competitive landscape. Empirical comparison vs Semgrep's p/ai-best-practices and other OSS / commercial options.
  • AI failure modes. Long-form rationale for why AI assistants reproduce each of these patterns.
  • Self-audit. The project's own security review, with findings and fixes shipped in 0.0.2.