This page is the authoring reference for SAST custom rules — UI fields, pattern syntax, metavariables, taint mode, scoping (per-org and per-repo), and worked examples for each supported language. Before authoring, check the SAST scan type page to see what TigerGate’s bundled packs already cover.
These custom rules are authored in the dashboard. You write the rule in the editor, click Validate to check it, then Validate & Save — and the next CI scan picks it up automatically, because the CLI fetches the merged ruleset from the backend at scan start. There is no tigergate-cli rules validate command. To iterate, scope the rule to a single repository first and watch one CI run.

Where rules live

Open Code Security → Rules Catalog (the SAST Rules page), then the Custom Rules tab → New rule. The editor has:
FieldSet inNotes
NameUI text fieldDisplay label in the Custom Rules list.
DescriptionUI text fieldOptional free-form context, shown in the catalog.
Primary languageUI dropdownOne of python, javascript, typescript, java, go, csharp, ruby, php, kotlin, swift, rust, scala, bash, terraform, dockerfile, generic. A catalog display label — the YAML body’s languages: array is what the scanner actually uses (see Anatomy).
Display severityUI dropdownOne of critical, high, medium, low, info. A display label for the Custom Rules list — it is not sent to the scanner. The severity that drives findings (and the quality gate) is the rule-file severity: inside the YAML body (see Anatomy).
Configure forUI dropdown (page header)Organization (default) = fires on every repo’s CI scan. <specific repo> = fires only when scanning that repo. See Scoping: org vs repo below.
Enabled (fires on scans)UI checkboxUnchecked = rule stays saved but doesn’t ship to scans.
Rule body (YAML)Code editorThe actual rule body — id, message, pattern…, etc. Validated server-side; see Anatomy.
Hit Validate to run server-side checks without saving, or Validate & Save to commit. Both call the same validator; Save additionally writes the row. The CLI fetches these rules from the backend, not from your repo. At scan time it calls GET /api/ci/sast-ruleset.yaml?repo=<repo-name> (the repo name, which the backend resolves to a repository ID), which returns every enabled rule the org has, including:
  • The catalog rule packs (OWASP, CWE, AI/LLM, TigerGate’s internal catalog).
  • All custom rules where repository_id IS NULL (org-scoped).
  • All custom rules where repository_id = <this repo> (repo-scoped).
The merged document is passed to the scanner as --config — there’s nothing to commit to your repo for these rules.

Anatomy of a rule

The YAML body is what you type in the editor. It uses TigerGate’s pattern rule syntax (AST-aware). Example:
id: no-direct-db-access-from-handlers
languages: [typescript]
severity: ERROR
message: |
  HTTP handlers must go through the repository layer.
  Direct DB calls bypass row-level security and audit logging.
pattern-either:
  - pattern: $DB.query(...)
  - pattern: $DB.execute(...)
paths:
  include:
    - src/handlers/
  exclude:
    - src/handlers/__tests__/
metadata:
  category: security
  cwe: "CWE-89"
  owasp: "A03:2021 - Injection"
  references:
    - https://owasp.org/Top10/A03_2021-Injection/
FieldRequiredWhat it does
idYesUnique within scope. 1–149 chars: letters, digits, _, -, ..
languagesYesNon-empty array of language identifiers. The validator accepts the full supported set — broader than the common list in the UI dropdown above, so identifiers like c, cpp, hcl, solidity, html, and json are also valid — but rejects any identifier it doesn’t recognize.
severityYesRule-file severity: ERROR / WARNING / INFO / INVENTORY / EXPERIMENT. This is what the scanner reads and what drives findings. The Display severity dropdown (critical / high / …) is a separate catalog-display label only — it is not sent to the scanner.
messageYesShown on every finding. First sentence = title; rest = body. Make it actionable.
One pattern formYespattern, patterns, pattern-either, pattern-regex, or a taint definition (sources/sinks).
paths.include / paths.excludeoptionalGlob patterns relative to the repo root.
metadataoptionalFree-form. cwe, owasp, category, references show in the dashboard finding card.
fixoptionalAuto-fix template. Triggers the “Apply suggestion” button on PRs.
The Primary language dropdown and the YAML body’s languages: array are independent — the dropdown is a catalog label and does not rewrite the YAML. The starter body defaults to languages: [python]; edit it to match the languages your pattern targets. The validator rejects a body whose languages: is missing or empty.

Pattern syntax

The pattern is written in the syntax of the target language — a Python pattern looks like Python, a Go pattern looks like Go.

pattern — match one expression

pattern: eval($USER_INPUT)
Matches eval(req.body.code), eval(form_data), eval(x) — anything passed as an argument to eval.

patterns — AND together

All sub-patterns must match.
patterns:
  - pattern: requests.get($URL, ...)
  - pattern-not: requests.get("https://" + ..., ...)
  - pattern-inside: |
      def $FN(...):
        ...
Reads as: “any call to requests.get that isn’t passing a hardcoded https:// URL, inside a function definition.”

pattern-either — OR together

Any one sub-pattern matches.
pattern-either:
  - pattern: hashlib.md5(...)
  - pattern: hashlib.sha1(...)
  - pattern: Crypto.Hash.MD5.new()

pattern-not — exclude a sub-match

Used inside patterns:.
patterns:
  - pattern: subprocess.$FN($CMD, ...)
  - pattern-not: subprocess.$FN($CMD, shell=False, ...)

pattern-inside / pattern-not-inside — scope

Match only when the code is (or isn’t) nested inside another pattern.
patterns:
  - pattern: $DB.query(...)
  - pattern-inside: |
      app.$ROUTE($PATH, function (...) {
        ...
      })

pattern-regex — fallback to regex

For things the parser can’t see (config files, raw strings). Less accurate — prefer structural patterns.
pattern-regex: 'AKIA[0-9A-Z]{16}'

Metavariables

Capital-prefixed identifiers starting with $ capture anything of the matching shape:
FormCaptures
$XA single expression (name, call, literal, …)
$...ARGSZero or more arguments / statements
...”anything goes here” inside the AST shape
$"...regex..."A string literal matching the regex
The same metavariable used twice = the matches must be equal:
pattern: |
  if $USER == $USER:
    ...
Matches the self-comparison bug if x == x:. Constrain further with metavariable-pattern / metavariable-regex:
patterns:
  - pattern: hashlib.new($ALGO, ...)
  - metavariable-regex:
      metavariable: $ALGO
      regex: '^(md5|sha1)$'

Taint mode (source → sink)

For data-flow vulnerabilities — SQLi, command injection, SSRF — match a flow, not a single call site.
id: tainted-sql-from-request
mode: taint
languages: [python]
severity: ERROR
message: User input flows into a raw SQL query without parameterisation.
pattern-sources:
  - pattern: request.$ANY
  - pattern: flask.request.$ANY
pattern-sinks:
  - pattern: $CURSOR.execute($SQL, ...)
  - pattern: $CURSOR.executemany($SQL, ...)
pattern-sanitizers:
  - pattern: sqlalchemy.text(...)
  - pattern: psycopg2.sql.SQL(...)
metadata:
  cwe: "CWE-89"
  owasp: "A03:2021 - Injection"
A finding fires when the scanner can prove a value from a source reaches a sink without passing through a sanitizer. Taint mode is more accurate than a plain pattern: rule but slower — only use it for true data-flow bugs.

Path scoping

paths:
  include:
    - src/api/
    - services/*/handlers/
  exclude:
    - "**/__tests__/**"
    - "**/*.spec.ts"
    - "**/fixtures/**"
Globs use the same syntax as .gitignore. include is union; exclude runs after include. Tests are the most common false-positive source — exclude them by default unless the rule is specifically about test code.

Auto-fix template

If the fix is mechanical, add a fix: block. The PR review surface renders it as a one-click commit suggestion:
id: insecure-md5
languages: [python]
severity: WARNING
message: MD5 is cryptographically broken. Use sha256.
pattern: hashlib.md5($DATA)
fix: hashlib.sha256($DATA)
For multi-line / context-sensitive fixes, use fix-regex:
fix-regex:
  regex: 'hashlib\.md5\((.*)\)'
  replacement: 'hashlib.sha256(\1)'
If no fix: is provided, TigerGate’s LLM still tries to suggest one at PR review for known remediation patterns — see AI Code Review.

Worked examples — per language

Python — pickle deserialization (taint)

UI: language = python, severity = critical. YAML body:
id: pickle-from-untrusted-source
mode: taint
languages: [python]
severity: ERROR
message: Pickle deserialization of untrusted input enables RCE.
pattern-sources:
  - pattern: request.$ANY
  - pattern: flask.request.$ANY
  - pattern: open($PATH, "rb").read()
pattern-sinks:
  - pattern: pickle.loads(...)
  - pattern: pickle.load(...)
metadata:
  cwe: "CWE-502"

TypeScript / JavaScript — forbidden child_process.exec

UI: language = typescript, severity = high. YAML body:
id: no-child-process-exec
languages: [javascript, typescript]
severity: ERROR
message: |
  child_process.exec is shell-interpolated and unsafe.
  Use child_process.execFile with an explicit args array.
pattern-either:
  - pattern: require("child_process").exec(...)
  - pattern: |
      import { exec } from "child_process";
      ...
      exec(...);
metadata:
  cwe: "CWE-78"
  owasp: "A03:2021 - Injection"

Java — Spring @Value reading a secret from properties

UI: language = java, severity = medium. YAML body:
id: no-secret-in-app-properties
languages: [java]
severity: WARNING
message: Load secrets from the secret manager, not application.properties.
pattern: |
  @Value("${$KEY}")
  $TYPE $FIELD;
metavariable-regex:
  metavariable: $KEY
  regex: '.*(secret|password|token|api[_-]?key).*'
metadata:
  cwe: "CWE-798"

Go — SQL injection via string concat

UI: language = go, severity = critical. YAML body:
id: go-sql-string-concat
languages: [go]
severity: ERROR
message: |
  Build the SQL with parameter placeholders ($1, $2, …) and pass values
  as separate args. String concatenation enables SQL injection.
patterns:
  - pattern-either:
      - pattern: $DB.Query("..." + $X + "...", ...)
      - pattern: $DB.Exec("..." + $X + "...", ...)
      - pattern: fmt.Sprintf("...SELECT...%s...", $X)
metadata:
  cwe: "CWE-89"

Terraform — public S3 bucket

UI: language = terraform, severity = high. YAML body:
id: tf-s3-public-acl
languages: [terraform]
severity: ERROR
message: S3 bucket ACL is public. Set acl = "private" and use a bucket policy if cross-account access is needed.
pattern: |
  resource "aws_s3_bucket" $NAME {
    ...
    acl = "public-read"
    ...
  }
metadata:
  cwe: "CWE-732"

Dockerfile — running as root

UI: language = dockerfile, severity = medium. YAML body:
id: dockerfile-no-user
languages: [dockerfile]
severity: WARNING
message: Dockerfile has no USER directive — container runs as root.
patterns:
  - pattern: FROM ...
  - pattern-not-inside: |
      FROM ...
      ...
      USER $U
metadata:
  cwe: "CWE-250"

Generic (regex) — hardcoded Stripe key

UI: language = generic, severity = critical. YAML body:
id: hardcoded-stripe-key
languages: [generic]
severity: ERROR
message: Stripe secret key is hardcoded — load from environment instead.
pattern-regex: 'sk_(live|test)_[A-Za-z0-9]{24,}'
paths:
  exclude:
    - "**/*.test.*"
    - "**/*.example.*"
metadata:
  cwe: "CWE-798"

Scoping: org vs repo

Custom rules can be scoped at two levels — set via the Configure for dropdown at the top of the SAST Rules page:
ScopeBehaviorWhen to use
Organization (default)Rule fires on every repo’s CI scan in the org.Codebase-wide invariants, forbidden APIs, architectural rules.
A specific repositoryRule fires only when scanning that repo.Rolling out a noisy rule to one repo first, repo-specific invariants.

How rules are picked at scan time

When a scan runs against repository X, the backend serves every enabled custom rule where:
repository_id IS NULL              -- org-scoped rules
   OR repository_id = <repo X>     -- rules scoped to this repo
Both sets are concatenated into the ruleset served to the scanner. There is no automatic deduplication — if an org-scoped rule and a repo-scoped rule share the same id, both go to the scanner and the result is undefined. Rule of thumb: keep ids unique across scopes. If you need a repo-specific override, give it a different ID (e.g. no-eval org-wide + no-eval-billing for the billing service) or delete the org-level rule before adding the repo-level one.

Database-level uniqueness

A single id is unique within its scope:
  • One id per org for org-scoped rules.
  • One id per (org, repo) for repo-scoped rules.
So an org-scoped no-eval and a repo-A-scoped no-eval and a repo-B-scoped no-eval can all coexist — the uniqueness check is per-row-shape, not global.

Performance & accuracy tips

  • Prefer pattern over pattern-regex — structural patterns are AST-aware and much more accurate.
  • Scope to paths. paths.include cuts scan time dramatically.
  • Use taint mode only for true data-flow bugs. ~5–10× slower than a plain pattern.
  • Avoid ... at the top of a pattern. It matches anywhere, usually not what you want.
  • Exclude tests unless the rule is specifically about test code.
  • Match the YAML’s severity: to the UI severity dropdown — keep them aligned so the quality gate behaves predictably.

How to iterate safely

A dashboard rule only runs once saved — the scan fetches the ruleset from the backend, so there’s no way to try an unsaved rule against a fixture locally. The safe-iteration flow is:
1

Author in a repo-scoped slot

Switch the Configure for dropdown at the top of the SAST Rules page to a single repository (the noisiest one is usually the best test bed).
2

Validate in the editor

Click Validate. The server checks the YAML, required fields, severity enum, id character set, supported languages, and that a pattern form is present. Errors render inline. Fix them. (It does not execute your pattern: against a parser — a pattern that’s valid YAML but invalid code in the target language passes Validate and only errors at scan time.)
3

Save and trigger one scan

Click Validate & Save, then trigger a CI run on a PR (or push to a branch). The CLI auto-fetches the rule on the next scan — no client-side config to touch.
4

Triage the findings

Inspect findings in Code Security → Findings. If the rule is too noisy, edit the YAML (tighten pattern-not, add paths.exclude) and re-scan.
5

Promote to org-wide

Once the noise is acceptable: delete the repo-scoped row, switch Configure for back to Organization (default), and re-create the rule there. Every repo now picks it up on its next CI scan.

Common validation errors

ErrorCauseFix
Define one of pattern, patterns, pattern-either, pattern-regex, or pattern-sourcesNo pattern form providedAdd one of the pattern fields
severity must be one of ERROR, WARNING, INFO, INVENTORY, EXPERIMENTLowercased severity inside the YAML, e.g. severity: highUse the uppercase rule-file enum. (The lowercase critical/high/… picker is the UI dropdown, separate field.)
id fails the character checkid has spaces, /, unicode, or is too long1–149 chars of letters, digits, underscore, hyphen, or dot; first char must be a letter or digit
languages must be a non-empty arraylanguages: python (string, not array)Wrap in []; each entry must be a supported language
Unknown top-level key 'severitye'Typo in a top-level key (warning, not error)Fix the key name
pattern parse error (surfaces in the CI scan log, not the Validate button)Pattern isn’t valid syntax in the target languageRewrite the pattern in the language’s real syntax and re-run the scan
The Validate button runs structural checks only — YAML well-formedness, required fields, the severity enum, the id character set, supported languages, and that a pattern form is present. It does not run your pattern: through the language parser, so a pattern that is valid YAML but invalid code in the target language passes Validate and only errors at scan time (last row).

Permissions

Creating, editing, and deleting custom rules requires the owner or admin role. Members and viewers can browse the Custom Rules list (read-only) but cannot author or change rules.