Claude Skill

semgrep-rule-variant-creator

Creates language variants of existing Semgrep rules. Use when porting a Semgrep rule to specified target languages. Takes an existing rule and target languages as input, produces independent rule+test directories for each language.

LLM Mart · 0 points · 11 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download trailofbits-skills-plugins_semgrep-rule-variant-creator_skills_semgrep-rule-variant-creator-123037e.zip · 16 KB
trailofbits/skills 7234 616 forks CC-BY-SA-4.0 Updated 1d ago
Part of trailofbits/skills — 100 skills

Install

skills CLI npx skills add https://github.com/trailofbits/skills/tree/main/plugins/semgrep-rule-variant-creator/skills/semgrep-rule-variant-creator
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install trailofbits-skills@llmmart
Git git clone https://github.com/trailofbits/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole trailofbits/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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-languages

Pass 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:

  1. Claude Code — ls -d -- "${CLAUDE_PLUGIN_ROOT}/skills/semgrep-rule-variant-creator/references"
  2. Codex — the same command with ${CODEX_PLUGIN_ROOT}, if that variable is set instead
  3. Neither set — find ~/.claude ~/.codex . -type d -path '*/semgrep-rule-variant-creator/skills/*/references' -print -quit 2>/dev/null

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.go

All 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

Files (skills)
  • agents
    • openai.yaml 254 B
      interface:
        display_name: "Semgrep Rule Variant Creator"
        short_description: "Adapt Semgrep rules to additional programming languages"
        icon_small: "assets/trail-of-bits-mark.svg"
        icon_large: "assets/trail-of-bits-mark.svg"
        brand_color: "#D83A34"
      
  • assets
    • trail-of-bits-mark.svg 3 KB · in bundle
  • references
    • applicability-analysis.md 10 KB
      # 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() variant
      ```
      
      ### NOT_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.
      
      ```sh
      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 piped `open` all reach `/bin/sh` — and CGI.pm, Plack and Mojolicious
        supply genuinely attacker-controlled sources. None of that matters: the only
        ways to touch a `.pl` file are `generic` and `regex`, neither of which has an
        AST or a dataflow engine, so `mode: taint` no-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 `--test` that 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
      
      ```yaml
      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 variants
      ```
      
      **Target**: 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 patterns
      ```
      
    • language-syntax-guide.md 7.3 KB
      # Language Syntax Translation Guide
      
      Guidance for translating Semgrep patterns between languages. This is NOT a pre-built mapping—use these principles to research and adapt patterns for your specific case.
      
      ## General Translation Principles
      
      ### 1. Never Assume Syntax Equivalence
      
      What looks similar may parse differently:
      
      ```python
      # Python: method call on object
      obj.method(arg)
      
      # Go: might be method OR field access + function call
      obj.Method(arg)      # Method call
      obj.Field(arg)       # Field holding function, then called
      ```
      
      **Always dump the AST** for your target language to see the actual structure.
      
      ### 2. Research Before Translating
      
      For each construct in the original rule:
      1. Search target language documentation for equivalent
      2. Look for multiple ways the same thing can be written
      3. Check if language idioms differ significantly
      
      ### 3. Preserve Detection Intent, Not Literal Syntax
      
      The goal is detecting the same vulnerability, not matching identical syntax.
      
      ```yaml
      # Original (Python) - detects eval of user input
      pattern: eval($USER_INPUT)
      
      # Go doesn't have eval() - what's the equivalent danger?
      # Research shows: template execution, reflect-based eval, etc.
      # Adapt to what actually creates the vulnerability in Go
      ```
      
      ## AST Analysis
      
      ### Always Dump the AST
      
      ```bash
      semgrep --dump-ast -l <target-language> test-file
      ```
      
      Compare how similar constructs are represented:
      
      ```python
      # Python
      cursor.execute(query)
      ```
      
      ```go
      // Go
      db.Query(query)
      ```
      
      The AST structure may differ significantly even for conceptually similar operations.
      
      ### Key Differences to Watch
      
      | Aspect | May Differ |
      |--------|-----------|
      | Method calls | Receiver position, syntax |
      | Function arguments | Named vs positional, defaults |
      | String handling | Interpolation, concatenation |
      | Error handling | Exceptions vs return values |
      | Imports | How namespaces work |
      
      ## Metavariable Adaptation
      
      ### Metavariables Work Cross-Language
      
      Semgrep metavariables (`$X`, `$FUNC`, etc.) work in all languages:
      
      ```yaml
      # Works in Python
      pattern: $OBJ.execute($QUERY)
      
      # Works in Java
      pattern: $OBJ.executeQuery($QUERY)
      
      # Works in Go
      pattern: $DB.Query($QUERY, ...)
      ```
      
      ### Ellipsis Behavior
      
      `...` matches language-appropriate constructs:
      - In Python: matches arguments, statements
      - In Go: matches arguments, statements (handles multi-return)
      - In Java: matches arguments, statements, annotations
      
      ## Common Translation Categories
      
      ### Database Queries
      
      **Research for your target language:**
      - Standard library database package
      - Popular ORM frameworks
      - Raw query execution methods
      
      Common patterns to look for:
      - Query execution methods
      - Prepared statement patterns
      - String interpolation into queries
      
      ### Command Execution
      
      **Research for your target language:**
      - Standard library process/exec package
      - Shell execution vs direct execution
      - Argument passing (array vs string)
      
      ### File Operations
      
      **Research for your target language:**
      - File open/read/write APIs
      - Path construction methods
      - Directory traversal patterns
      
      ### HTTP Handling
      
      **Research for your target language:**
      - Request parameter access
      - Header access
      - Body parsing
      
      ## Researching Equivalents
      
      ### Step 1: Identify What the Original Detects
      
      Parse the original rule:
      - What function/method is the sink?
      - What's the vulnerability being detected?
      - What makes it dangerous?
      
      ### Step 2: Search Target Language Docs
      
      Search for:
      - `"<target language> <functionality>"` (e.g., "golang exec command")
      - `"<target language> <vulnerability>"` (e.g., "java sql injection")
      - Standard library documentation
      - [Semgrep Pattern Examples](https://semgrep.dev/docs/writing-rules/pattern-examples) - Per-language pattern references
      
      ### Step 3: Find All Variants
      
      A single Python function may have multiple equivalents:
      
      ```python
      # Python has one main way
      os.system(cmd)
      ```
      
      ```java
      // Java has multiple
      Runtime.getRuntime().exec(cmd);
      new ProcessBuilder(cmd).start();
      ProcessBuilder.command(cmd).start();
      ```
      
      Include all common variants in your rule.
      
      ### Step 4: Check for Idioms
      
      Languages have preferred patterns:
      
      ```python
      # Python: often inline
      cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
      ```
      
      ```go
      // Go: typically uses placeholders
      db.Query("SELECT * FROM users WHERE id = ?", userID)
      // Vulnerability is when they DON'T use placeholders
      db.Query("SELECT * FROM users WHERE id = " + userID)
      ```
      
      ## Source Pattern Translation
      
      ### Web Framework Sources
      
      Original rule sources need framework-specific translation:
      
      ```yaml
      # Python Flask
      pattern: request.args.get(...)
      
      # Java Servlet
      pattern: $REQUEST.getParameter(...)
      
      # Go net/http
      pattern: $R.URL.Query().Get(...)
      pattern: $R.FormValue(...)
      
      # Node.js Express
      pattern: $REQ.query.$PARAM
      pattern: $REQ.body.$PARAM
      ```
      
      ### User Input Sources
      
      Research common input sources for target language, for example:
      - HTTP request parameters
      - Command line arguments
      - Environment variables
      - File reads
      - Standard input
      
      ## Sanitizer Translation
      
      ### Research Sanitization Patterns
      
      Each language has different sanitization approaches:
      
      ```python
      # Python
      shlex.quote(cmd)  # Shell escaping
      html.escape(s)    # HTML escaping
      ```
      
      ```go
      // Go
      template.HTMLEscapeString(s)
      // Prepared statements (implicit sanitization)
      db.Query("SELECT ... WHERE id = ?", id)
      ```
      
      ```java
      // Java
      StringEscapeUtils.escapeHtml4(s)
      PreparedStatement (implicit sanitization)
      ```
      
      ## Import/Namespace Considerations
      
      ### Pattern May Need Context
      
      Some languages require matching imports:
      
      ```yaml
      # Python - function in global namespace after import
      pattern: pickle.loads(...)
      
      # Java - may need full path or import context
      pattern: java.io.ObjectInputStream
      pattern: ObjectInputStream
      ```
      
      ### When to Use Full Paths
      
      - When function name is common/ambiguous
      - When you want to match specific library
      - When namespace matters for security
      
      ## Testing Your Translation
      
      ### Verify with AST Dump
      
      After writing test cases, verify patterns match:
      
      ```bash
      # Dump AST of test file
      semgrep --dump-ast -l <lang> test-file
      
      # Compare with your pattern
      # Adjust pattern to match AST structure
      ```
      
      ### Test Edge Cases
      
      Each language has unique edge cases:
      - Different string types (Go: string vs []byte)
      - Different call syntaxes (method chaining)
      - Different argument patterns
      
      ## Example: Translating SQL Injection Rule
      
      **Original (Python):**
      ```yaml
      pattern-sinks:
        - pattern: $CURSOR.execute($QUERY, ...)
      ```
      
      **Research for Go:**
      1. Standard database package: `database/sql`
      2. Query methods: `Query`, `QueryRow`, `Exec`, `QueryContext`, etc.
      3. ORM equivalents: GORM, sqlx, etc.
      
      **Translated (Go - standard library):**
      ```yaml
      pattern-sinks:
        - pattern: $DB.Query($QUERY, ...)
        - pattern: $DB.QueryRow($QUERY, ...)
        - pattern: $DB.Exec($QUERY, ...)
        - pattern: $DB.QueryContext($CTX, $QUERY, ...)
      ```
      
      **Research for Java:**
      1. JDBC: `Statement`, `PreparedStatement`
      2. Query methods: `executeQuery`, `executeUpdate`, `execute`
      
      **Translated (Java):**
      ```yaml
      pattern-sinks:
        - pattern: (Statement $S).executeQuery($QUERY)
        - pattern: (Statement $S).executeUpdate($QUERY)
        - pattern: (Statement $S).execute($QUERY)
      ```
      
      ## Checklist Before Writing Rule
      
      - [ ] Dumped AST for target language test file
      - [ ] Researched equivalent functions/methods
      - [ ] Identified all common variants
      - [ ] Checked for language-specific idioms
      - [ ] Identified appropriate source patterns
      - [ ] Identified appropriate sanitizer patterns
      - [ ] Verified patterns match AST structure
      
    • workflow.md 3.9 KB
      # Variant Creation Mechanics and Troubleshooting
      
      The orchestration lives in `workflows/port-rule-to-languages.js` at the plugin root, which
      runs as `/semgrep-rule-variant-creator:port-rule-to-languages`. It owns the phase order,
      the recheck of a `NOT_APPLICABLE` verdict, and the validation retry. This file holds what
      the script cannot encode: the mechanics inside a phase, and what to do when a pattern will
      not match or taint will not flow.
      
      ## Core Principle: Independent Cycles
      
      The ordering constraint is per language, not global. Within a language the phases are
      strictly sequential — applicability decides whether there is anything to do, the tests
      specify the rule, and validation is what finishes it — and no phase may start before the
      one before it is done.
      
      What must not happen is batching a phase across languages: writing every test file, then
      every rule, then validating everything. Errors compound that way and the failure is hard
      to attribute. Separate languages are otherwise independent, so one may reach translation
      while another is still being assessed, which is how the workflow runs them.
      
      A language whose tests do not pass is unfinished, not "mostly done".
      
      ## Annotation Placement
      
      The annotation comment must be on the line immediately before the code it grades. This is
      the single most common way a port fails for a reason that has nothing to do with the rule:
      
      ```go
      // ruleid: my-rule
      vulnerableCode()  // this line gets flagged
      
      // ok: my-rule
      safeCode()  // this line must NOT be flagged
      ```
      
      An annotation followed by a blank line, or by another annotation, grades the wrong line.
      Semgrep reports that as a missed or incorrect line, which reads exactly like a pattern bug
      and sends you looking in the wrong place.
      
      ## Reading a Test Failure
      
      `semgrep --test` reports one of three things.
      
      Passing:
      
      ```
      1/1: ✓ All tests passed
      ```
      
      Missed lines — the rule did not match where it should have, so the pattern is narrower
      than the vulnerability:
      
      ```
      ✗ python-command-injection-go
        missed lines: [15, 22]
      ```
      
      Check for a pattern that is too specific, a missing pattern variant, or an AST structure
      that does not look the way the pattern assumes.
      
      Incorrect lines — the rule matched where it should not have, so the pattern is broader
      than the vulnerability:
      
      ```
      ✗ python-command-injection-go
        incorrect lines: [30, 35]
      ```
      
      Check for a pattern that is too broad, a missing `pattern-not` exclusion, or a sanitizer
      the rule does not know about.
      
      ## Troubleshooting
      
      ### Pattern Not Matching
      
      1. **Dump the AST**: `semgrep --dump-ast -l <lang> file`
      2. **Compare structure**: your pattern against the actual AST, not against the source text
      3. **Check metavariables**: is each one binding what you think it binds?
      4. **Start broader**: match too much on purpose, then narrow until the safe cases pass
      
      ### Taint Not Propagating
      
      Run `semgrep --dataflow-traces -f rule.yaml file`. It shows where taint originates, how it
      propagates, where it reaches a sink, and where it stops — which is usually the answer.
      
      1. **Check sanitizers**: one that is too broad silently kills the flow
      2. **Verify sources**: is the source pattern matching at all? Test it as a plain pattern
      3. **Check `focus-metavariable`**: is it on the part of the sink that receives the input?
      
      ### Too Many False Positives
      
      1. **Add `pattern-not`**: exclude the shapes that are actually safe
      2. **Add sanitizers**: the language's own validation and quoting functions
      3. **Use `pattern-inside`**: limit the rule to the context where the risk exists
      4. **Re-read the safe cases**: are they actually safe, or is the rule right and the test wrong?
      
      ### YAML Syntax Errors
      
      1. **Run `--validate`**: it names the problem
      2. **Check indentation**: YAML is whitespace-sensitive and Semgrep's nesting is deep
      3. **Quote strings**: anything containing `:`, `#`, `{`, or a leading `*` needs quoting
      4. **Use a block scalar**: `|` or `>-` for patterns that span lines
      
  • SKILL.md 13.4 KB
    ---
    name: semgrep-rule-variant-creator
    description: Creates language variants of existing Semgrep rules. Use when porting a Semgrep rule to specified target languages. Takes an existing rule and target languages as input, produces independent rule+test directories for each language.
    allowed-tools: Bash Read Write Edit Glob Grep WebFetch Workflow
    ---
    
    # 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-languages
    ```
    
    Pass 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:
    
    1. **Claude Code** — `ls -d -- "${CLAUDE_PLUGIN_ROOT}/skills/semgrep-rule-variant-creator/references"`
    2. **Codex** — the same command with `${CODEX_PLUGIN_ROOT}`, if that variable is set instead
    3. **Neither set** — `find ~/.claude ~/.codex . -type d -path '*/semgrep-rule-variant-creator/skills/*/references' -print -quit 2>/dev/null`
    
    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.
    
    ```json
    {
      "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]({baseDir}/references/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]({baseDir}/references/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]({baseDir}/references/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.go
    ```
    
    `All 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](https://semgrep.dev/docs/writing-rules/pattern-syntax) — metavariables and matching
    - [Pattern Examples](https://semgrep.dev/docs/writing-rules/pattern-examples) — per-language references, the most useful page when translating
    - [Testing Rules](https://semgrep.dev/docs/writing-rules/testing-rules) — annotation semantics
    - [Trail of Bits Testing Handbook](https://appsec.guide/docs/static-analysis/semgrep/advanced/) — advanced taint patterns
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related