Semgrep Rule Variant Creator
Trail of BitsOfficialPublished by Trail of Bits
- Skill
- Workflow
- Security
- CC-BY-SA-4.0
- No issues found
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a systematic workflow for porting existing Semgrep rules to different programming languages. It enforces a four-phase process for each target language: applicability analysis, test creation, rule translation, and validation. By requiring that tests are written before the rule and that each language variant is validated independently, it ensures that ported rules are tailored to the specific idioms and AST structures of the target language rather than relying on direct syntax translation.
What it does
- Analyzes whether a vulnerability pattern is applicable to a target language.
- Generates idiomatic test cases including both vulnerable and safe code examples.
- Translates Semgrep rules by analyzing target language ASTs.
- Validates ported rules against test cases using Semgrep.
- Provides a structured workflow for managing multi-language rule ports.
- Supports resuming interrupted porting sessions.
When to use it
- When you need to extend an existing security rule to cover additional programming languages.
- When you want to ensure that a ported rule is tested against language-specific idioms.
- When you need to verify if a vulnerability class is relevant in a new target language.
- When you are managing a large-scale rule porting project across multiple languages.
Advantages
- Enforces a test-first approach to rule development.
- Reduces errors by handling each language variant in an isolated, repeatable cycle.
- Prevents invalid ports by requiring an applicability analysis phase.
- Ensures rule accuracy by requiring validation against specific test cases.
Usage examples
- “Port the existing command injection rule to Go and Java.”
- The agent initiates the workflow, performing applicability analysis, creating test files, translating the rule, and validating the results for both languages.
- “Resume the interrupted porting run for the SQL injection rule.”
- The agent resumes the workflow from the last successful state, replaying completed language variants from cache and continuing with the remaining tasks.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/trailofbits/semgrep-rule-variant-creator/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip semgrep-rule-variant-creator.zip -d .agents/skills
Documentation by Trail of Bits
Semgrep Rule Variant Creator
Port an existing Semgrep rule to other languages, one independent test-driven cycle per language.
For a new rule rather than a port, use semgrep-rule-creator — it takes a bug pattern
description where this skill takes a finished rule. That skill is also the reference for
rule-writing fundamentals: taint mode versus pattern matching, why tests come first, and
how to narrow a rule once it passes. Porting applies those same judgments in a new
language, so start there when the rule structure itself is the open question.
Run it as a workflow
Porting is the same four phases repeated per language, so the orchestration ships as a dynamic workflow rather than as instructions to re-follow each run:
/semgrep-rule-variant-creator:port-rule-to-languagesPass the three required arguments, and outputDir unless the working directory is where you
want the variants. One language per entry: "Go and Java" ports a single language named after
the phrase, and the script rejects it.
referencesDir has to be a resolved absolute path. Resolve it here, because no workflow script
can expand a variable. Try in order, first hit wins — the -d is the point, since a bare ls
prints the names of the files inside the directory rather than the directory itself and leaves
nothing to copy:
Claude Code —
ls -d -- "${CLAUDE_PLUGIN_ROOT}/skills/semgrep-rule-variant-creator/references"Codex — the same command with
${CODEX_PLUGIN_ROOT}, if that variable is set insteadNeither set — resolve
{baseDir}/referencesfrom the directory containing this loaded SKILL.md and print its absolute path withls -d -- "<resolved path>". Do not search caches or the target project for another copy.
Then confirm the directory that printed holds both reference files, with ls -1 -- "<that path>".
Pass the path exactly as printed. If all three come back empty, stop and say so rather than assembling a path by hand: the script rejects a relative path and an unexpanded token, but a hand-built absolute path that happens not to exist clears every guard it has, and the run then reports every language as passed having read no guidance at all.
{
"rulePath": "<path to the rule being ported>",
"languages": ["Go", "Java"],
"referencesDir": "<the absolute path the ls above printed>",
"outputDir": "<where the variant directories should land>"
}A workflow script cannot expand {baseDir} or ${CLAUDE_PLUGIN_ROOT}, and has no filesystem
access to notice that it did not; an installed plugin does not sit in the user's project
either, so referencesDir is the only route by which the references below reach the phase
agents. The script rejects a run that omits it and one that passes a token instead of a path,
rather than porting without them, since a port made without this guidance still reports every
language as passed. outputDir is the one optional argument, defaulting to the working
directory, which is rarely what you want inside a repository.
It reads the rule once, then runs each language through the full cycle independently, and
reports which languages passed, which failed validation, which it judged not applicable, which
Semgrep cannot analyze at all, and which it stopped on — a language key it does not recognize,
two entries resolving to one directory, or a refuter that never reported back. A stop names
what to change and will happen again on a re-run, which is what separates it from an agent that
died. The rule travels as a path, not as text: every phase
reads the file, because an agent asked to repeat a rule back verbatim does not — one
HTML-escaped < and > and broke the <... ...> operator for every phase downstream.
If a run is interrupted while the session is still alive — you stopped it, or an agent hit a
terminal error — relaunch it with
Workflow({scriptPath: "…", resumeFromRunId: "<runId>", args: {…}}), passing the same
arguments again. Arguments are not saved with a run, so a resume that omits them fails the
pre-flight check above before replaying anything; with them, languages that finished replay
from cache and only the unfinished ones re-run.
Resume is same-session only, which rules it out for the interruption a long port is most likely to hit: a session limit ends the session, and runs are stored under that session's own directory, so the next session cannot reach them. A run id it cannot resolve is not an error either — the workflow starts from scratch under that id and re-runs every language at full cost, with nothing saying so. Check the id is still there before counting on a resume:
ls -d ~/.claude/projects/*/*/subagents/workflows/*/That is where runs land today rather than a documented interface, so an empty result may mean the
layout moved rather than that the run is gone. The safe reading is the same either way: if you
cannot confirm the id, or the session ended, re-invoke with the same arguments and point
outputDir somewhere fresh. The script never deletes a directory, so a language that flipped to
NOT_APPLICABLE on the second run leaves the first run's variant behind.
The script is workflows/port-rule-to-languages.js at the plugin root. It pins a
reasoning effort per phase — cheap to read the rule, highest for translation and for the
fix-until-green loop — and encodes the phase order, so a rule cannot be written before
the tests that specify it. It also keeps the two decisions that have no oracle out of any
single agent's hands: a NOT_APPLICABLE verdict goes to an independent refuter before the
language is dropped, and failed validation is retried up to three times rather than
trusting one agent to iterate until green.
Run the phases by hand when you are porting to a single language and want to stay in the loop, or when a port is already half-finished and you only need one phase. The workflow is the only delegation a port needs: one agent to read the rule, and four per language when the port goes green first try — a refuted verdict adds one, and so does each validation retry. Nothing else here is large or independent enough to be worth its own agent, so running a phase by hand means doing it yourself rather than handing it to a subagent.
The four phases
Each language runs all four before its variant is finished. A language that fails validation is unfinished; a language judged not applicable produces no directory.
1. Applicability analysis — decide whether the pattern belongs in the target language
at all: does the vulnerability class exist there, does an equivalent construct exist for
each source, sink, and sanitizer, and would the ported rule detect real risk rather than
a surface syntax match. Verdict is APPLICABLE, APPLICABLE_WITH_ADAPTATION, or
NOT_APPLICABLE. NOT_APPLICABLE is the one verdict nothing downstream can contradict —
it produces no tests, no rule, and no directory — so it earns a second opinion before you
act on it. Answered separately, by running Semgrep: can Semgrep read this language at all?
Perl has no frontend and Elixir's parser is Pro-only, and in both cases the bug class is
present while the rule is ungradeable — a different finding from NOT_APPLICABLE, which
claims the bug class is absent. See
applicability-analysis.md
for worked examples of each verdict.
2. Test creation — write the test file first, in idiomatic target-language code. At
least two ruleid: cases and two ok: cases, each annotation on the line immediately
above the code it grades. Include the safe form that is the language's own idiom for
doing the thing correctly, since that is the false positive a port most often invents.
3. Rule translation — dump the AST for the target language and translate against what
it shows, because pattern shape follows AST shape rather than source resemblance. Keep the
original's detection intent and mode; change the id to <original-id>-<language>, the
languages key, and add original-rule and ported-from metadata. See
language-syntax-guide.md.
4. Validation — semgrep --test is the acceptance criterion, and it must report that
all tests passed. Missed lines mean the pattern is narrower than the vulnerability;
incorrect lines mean it is broader. The test file is the specification, so fix the rule to
satisfy it. Stopping while tests still fail leaves the language unfinished, not done. See
workflow.md for reading a test failure and for
troubleshooting when a pattern will not match or taint will not propagate.
The acceptance criterion is one specific Semgrep: the version recorded when the rule was read. Switching binaries to get a green is the failure this guards against — an agent that could not pass its Elixir tests installed the last OSS build shipping the Elixir parser and reported its genuine "All tests passed" for a port that is red here. Two other greens mean nothing: a rule Semgrep skipped still ends its run in "All tests passed", and so does a test file whose extension Semgrep does not associate with the rule's language, since it graded zero tests either way.
Output
One directory per applicable language, holding the ported rule and its test file:
python-command-injection-go/
├── python-command-injection-go.yaml
└── python-command-injection-go.goAll tests passed means the rule and its test file agree with each other; it is not
evidence that the vulnerability class is exploitable in the target language, since the same
cycle wrote both, so treat a finished variant as a candidate for review rather than a
validated rule.
Scope and reporting
Port the rule you were handed to the languages you were asked for. Do not repair the original, widen it to catch a nearby bug class, or add a language nobody named; if the original looks wrong or an obvious target is missing, say so in one sentence and carry on with the port as asked. Every language you were given gets finished — a port is done when its tests pass, not when its files exist.
Keep prose short and spend it on the result. Before the first tool call, say in one
sentence what you are about to do. While a port runs, speak up when a verdict changes,
when the target needs a pattern shape the original does not have, or when the tests will
not go green — not on every semgrep --test iteration. Then lead with the outcome: the
first sentence says which languages passed, which failed validation, which were not
applicable, and which Semgrep cannot analyze, with the detail after it. Correct an earlier statement when the error changes
the rule, the verdict, or what to do next, then keep going; a slip that changes nothing
needs no note.
Rule and test files are the size of the problem. A test file earns its length from distinct constructs and distinct safe forms rather than from restatements of the same case, and neither file needs comments repeating what the code already says.
Rationalizations to Reject
| Rationalization | Why It Fails | Correct Approach |
|---|---|---|
| "Pattern structure is identical" | Different ASTs across languages | Always dump AST for target language |
| "Same vulnerability, same detection" | Data flow differs between languages | Analyze target language idioms |
| "Rule doesn't need tests since original worked" | Language edge cases differ | Write NEW test cases for target |
| "Skip applicability - it obviously applies" | Some patterns are language-specific | Complete applicability analysis first |
| "I'll create all variants then test" | Errors compound, hard to debug | Finish each language before the next |
| "Library equivalent is close enough" | Surface similarity hides differences | Verify API semantics match |
| "Just translate the syntax 1:1" | Languages have different idioms | Research target language patterns |
| "Most tests pass" | A partial rule reports partial truth | All tests passed, or the port is unfinished |
| "An older semgrep still parses this language" | A green nobody can reproduce on the semgrep the rule must run under | Report the failing output and say the parser is Pro-only |
| "The class exists there, so the rule ports" | Semgrep has no Perl frontend and Elixir's is Pro-only; taint no-ops silently | Confirm semgrep can read the language before porting |
| "Semgrep said all tests passed" | It says that over zero graded tests, for a rule it skipped or a file it never matched | Check the rule ran and the test file's extension matches |
Quick Reference
| Task | Command |
|---|---|
| Run tests | semgrep --test --config rule.yaml test-file |
| Validate YAML | semgrep --validate --config rule.yaml |
| Dump AST | semgrep --dump-ast -l <lang> <file> |
| Debug taint flow | semgrep --dataflow-traces -f rule.yaml file |
Documentation
Pattern Syntax — metavariables and matching
Pattern Examples — per-language references, the most useful page when translating
Testing Rules — annotation semantics
Trail of Bits Testing Handbook — advanced taint patterns
Files
- ATTRIBUTION.mdAdded by AutoGineer978 B
- LICENSELicence file added by AutoGineer19.7 KiB
- SKILL.md13.5 KiB
- agents/openai.yaml254 B
- assets/trail-of-bits-mark.svg3.0 KiB
- references/applicability-analysis.md10.0 KiB
- references/language-syntax-guide.md7.3 KiB
- references/workflow.md3.9 KiB
references/applicability-analysis.md
Applicability Analysis
Phase 1 of the variant creation workflow. Before porting a rule, analyze whether the vulnerability pattern applies to the target language.
Analysis Process
For EACH target language, answer these questions:
1. Does the Vulnerability Class Exist?
Determine if the vulnerability type is possible in the target language.
Examples:
Buffer overflow: Applies to C/C++, may apply to Rust (in unsafe blocks), does NOT apply to Python/Java
SQL injection: Applies to any language with database access
XSS: Applies to any language generating HTML output
Memory leak: Relevant in C/C++, less relevant in garbage-collected languages
Type confusion: Relevant in dynamically typed languages, less relevant in strongly typed
2. Does an Equivalent Construct Exist?
Identify what the original rule detects and find equivalents.
Parse the original rule to identify:
Sinks: What dangerous functions/methods does it detect?
Sources: Where does tainted data originate?
Pattern type: Is it taint-mode or pattern-matching?
Then research the target language:
What are the equivalent dangerous functions?
What are the common source patterns?
Are there language-specific idioms to consider?
3. Are the Semantics Similar Enough?
Verify the pattern translates meaningfully.
Consider:
Does the vulnerability manifest the same way?
Are there language-specific mitigations that change detection needs?
Would the ported rule provide actual security value?
Verdict Format
Document your analysis for each target language:
TARGET: <language>
VERDICT: APPLICABLE | APPLICABLE_WITH_ADAPTATION | NOT_APPLICABLE
REASONING: <specific analysis>
ADAPTATIONS_NEEDED: <if APPLICABLE_WITH_ADAPTATION>
EQUIVALENT_CONSTRUCTS:
- Original: <function/pattern>
- Target: <equivalent function/pattern>Verdict Definitions
APPLICABLE
The pattern translates directly with minor syntax adjustments.
Criteria:
Equivalent constructs exist with same semantics
Vulnerability manifests identically
Detection logic remains the same
Example:
Original: Python os.system(user_input)
Target: Go exec.Command(user_input)
VERDICT: APPLICABLE
REASONING: Both execute shell commands with user input. Vulnerability is
identical (command injection). Detection logic (taint from input to exec)
translates directly.APPLICABLE_WITH_ADAPTATION
The pattern can be ported but requires significant changes.
Criteria:
Vulnerability class exists but manifests differently
Equivalent constructs exist but with different APIs
Additional patterns needed for target language idioms
Example:
Original: Python pickle.loads(untrusted)
Target: Java ObjectInputStream.readObject()
VERDICT: APPLICABLE_WITH_ADAPTATION
REASONING: Both detect deserialization vulnerabilities but the APIs differ
significantly. Java requires detection of ObjectInputStream creation and
readObject() calls, not a single function call.
ADAPTATIONS_NEEDED:
- Different sink patterns (readObject vs loads)
- May need pattern-inside for ObjectInputStream context
- Consider readUnshared() variantNOT_APPLICABLE
The pattern should not be ported to this language.
Criteria:
Vulnerability class doesn't exist in target language
No equivalent construct exists
Pattern would be meaningless or misleading
Example:
Original: C strcpy/strncpy detection (CWE-676, use of a dangerous function)
Target: Python
VERDICT: NOT_APPLICABLE
REASONING: The rule detects an unbounded/bounded pair where a safer
replacement exists — strcpy -> strcpy_s, strncpy -> a variant that
NUL-terminates. Python has neither half. str and bytes are immutable and
length-prefixed, bytearray slice assignment resizes or raises, and there is
no NUL-termination contract to omit, so the ported sink would match only
memory-safe code.Note what that reasoning does not say. "Python is memory-safe, so buffer
overflows cannot happen" is false, and reaching for it will get a verdict
overturned: ctypes.memmove(create_string_buffer(8), b"B"*64, 64) writes 64
bytes into an 8-byte buffer from pure Python and segfaults the interpreter.
The verdict holds on the sink, not on the language's reputation — memmove is
the analogue of memcpy, there is no memmove_s to recommend, and telling a
Python developer to use strcpy_s misattributes the finding. A ctypes rule
is worth writing; it is a different rule, not this one ported.
Reach for a language's safety reputation and you will overshoot. Check the specific construct the rule names.
Can Semgrep Analyze the Target at All?
Separate from the verdict, and answered by running Semgrep rather than from memory. The three questions above ask whether the bug exists in the target. This one asks whether Semgrep can see it, and a "no" stops the port however applicable the pattern is.
semgrep show supported-languages # is there a key for this language?
semgrep --dump-ast -l <key> probe.<ext> # does the parser actually run?Two ways it fails, both silent:
No frontend. Perl is not a Semgrep language. Command injection is if anything worse there than in Python —
system("cmd $x"), backticks,qx{}, two-arg pipedopenall reach/bin/sh— and CGI.pm, Plack and Mojolicious supply genuinely attacker-controlled sources. None of that matters: the only ways to touch a.plfile aregenericandregex, neither of which has an AST or a dataflow engine, somode: taintno-ops and returns zero findings at ~100% "parsed".A Pro-only parser. Elixir left OSS Semgrep in 1.51.0. A rule declaring
languages: [elixir]is skipped rather than run: "1 rule(s) were skipped because they require Pro". Under--testthat does not surface as a failure — the run ends in "All tests passed" over zero graded tests, which is why nothing downstream catches it and why this question has to be settled here by running semgrep rather than inferred from a green. The tempting fix — an older Semgrep that still ships the parser — produces a green nobody can reproduce.
Report this as semgrepCanAnalyze, and say which of the two questions is
failing. Folding "Semgrep cannot read this language" into NOT_APPLICABLE
claims the bug class is absent, which is a different and often false statement.
Common Applicability Patterns
Always Translate (Language-Agnostic Vulnerabilities)
These vulnerability classes exist across most languages:
SQL injection (any language with DB access)
Command injection (any language with shell execution)
Path traversal (any language with file operations)
SSRF (any language with HTTP clients)
XSS (any language generating HTML)
Sometimes Translate (Context-Dependent)
These require careful analysis:
Deserialization: Different mechanisms per language
Cryptographic weaknesses: Language-specific crypto libraries
Race conditions: Depends on concurrency model
Integer overflow: Depends on type system
Rarely Translate (Language-Specific)
These are often NOT_APPLICABLE for other languages:
Memory corruption (C/C++ specific)
Type juggling (PHP specific)
Prototype pollution (JavaScript specific)
GIL-related issues (Python specific)
Library-Specific Rules
When the original rule targets a third-party library:
Step 1: Identify the Library's Purpose
What functionality does the library provide?
ORM / Database access
HTTP client/server
Serialization
Templating
etc.
Step 2: Research Target Language Ecosystem
For the target language, identify:
Standard library equivalents
Popular third-party libraries with same functionality
Language-specific idioms for this functionality
Step 3: Decide on Scope
Options:
Native constructs only: Port to standard library equivalents
Popular library: Port to the most common library in target ecosystem
Multiple variants: Create separate rules for multiple libraries
Recommendation: Start with standard library or most popular option. Additional library variants can be created separately if needed.
Analysis Checklist
Before proceeding past Phase 1:
[ ] Parsed original rule and identified pattern type
[ ] Identified sinks, sources, and sanitizers (if taint mode)
[ ] Researched equivalent constructs in target language
[ ] Documented verdict with specific reasoning
[ ] If APPLICABLE_WITH_ADAPTATION, listed required changes
[ ] If NOT_APPLICABLE, documented clear explanation
Example Analysis
Original Rule: Python command injection via subprocess
rules:
- id: python-command-injection
mode: taint
languages: [python]
pattern-sources:
- pattern: request.args.get(...)
pattern-sinks:
- pattern: subprocess.call($CMD, shell=True, ...)Target: Go
TARGET: Go
VERDICT: APPLICABLE_WITH_ADAPTATION
REASONING:
- Command injection exists in Go (vulnerability class present)
- Go uses exec.Command() and exec.CommandContext() for command execution
- Go doesn't have shell=True equivalent; commands run directly by default
- Shell execution in Go requires explicit bash -c wrapping
EQUIVALENT_CONSTRUCTS:
- Original sink: subprocess.call(cmd, shell=True)
- Target sinks:
- exec.Command("bash", "-c", cmd)
- exec.Command("sh", "-c", cmd)
- exec.Command(cmd) when cmd comes from user input
ADAPTATIONS_NEEDED:
1. Different sink patterns for Go's exec package
2. Source patterns need Go HTTP handler equivalents (r.URL.Query(), r.FormValue())
3. Consider both direct exec.Command and shell-wrapped variantsTarget: Java
TARGET: Java
VERDICT: APPLICABLE
REASONING:
- Command injection exists in Java (vulnerability class present)
- Java uses Runtime.exec() and ProcessBuilder for command execution
- Direct equivalent functionality available
EQUIVALENT_CONSTRUCTS:
- Original sink: subprocess.call(cmd, shell=True)
- Target sinks:
- Runtime.getRuntime().exec(cmd)
- new ProcessBuilder(cmd).start()
ADAPTATIONS_NEEDED:
- Source patterns need Java servlet equivalents (request.getParameter())
- Consider both Runtime.exec and ProcessBuilder patternsRequirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Declares allowed tools, Needs Python
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- Trail of Bits
- Publisher
- Trail of Bits (Official)
- Source
- trailofbits/skills · plugins/semgrep-rule-variant-creator/skills/semgrep-rule-variant-creator · commit e4dd0bb
- Licence
- Creative Commons Attribution-ShareAlike 4.0 International (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Trail of Bits or Trail of Bits.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download semgrep-rule-variant-creator.zip