Skip to main content

Custom Rules

The bundled rule sets know the public mistakes. Your codebase has its own: an internal HTTP client that must not be bypassed, a token format only your systems issue, a Terraform module that must always set a tag. Custom rules add those checks to every scan the deployment runs, for all three rule-based engines.

EngineDirectoryFormat
SAST (OpenGrep)sast/Semgrep rule files (*.yml, *.yaml) — rules from the Semgrep registry or an existing .semgrep/ directory work unchanged
Secrets (Gitleaks)secrets/gitleaks.tomlOne Gitleaks configuration: extra rules and allowlists
IaC (Checkov)iac/Checkov YAML custom policies (*.yml, *.yaml)

Where the rules live​

On the host that runs the platform, in the directory mounted read-only into the scanning containers — by default ./data/custom-rules next to docker-compose.yml (override with CODE_SCAN_CUSTOM_RULES_DIR in .env):

custom-rules/
├── sast/ acme-http-client.yml
├── secrets/ gitleaks.toml
└── iac/ acme-required-tags.yaml

Rules apply to every repository scanned by the deployment. Repository-specific rules belong in the repository itself (a .semgrep/ directory or .gitleaks.toml), which the engines honour as they always have.

Hot reload​

Every scan fingerprints the directory. If anything changed since the last scan, the rules are re-validated and staged again; otherwise the previous validated set is reused. Adding, editing or deleting a file is live on the next scan — no container restart, no image rebuild.

Validation is per file and a bad file is quarantined, not fatal: the valid ones still run, and the scan is never failed by a rule. This matters because one malformed YAML would otherwise make OpenGrep abort and silently zero the SAST results.

What the last scan used — and which files were rejected, with the reason — is one call away:

GET /api/code/scan-rules/custom # what the last scan used
GET /api/code/scan-rules/custom?refresh=true # re-validate right now
{
"root": "/data/custom-rules", "mounted": true, "fingerprint": "364ae2a7c97e91d6",
"sast": {"files": 1, "rules": 2, "rule_ids": ["acme-no-raw-requests"], "active": true,
"errors": [{"file": "engine-bad.yml", "error": "invalid rule engine-bad, engine-bad.yml:6:13: …"}]},
"secrets": {"files": 1, "rules": 2, "errors": [], "active": true},
"iac": {"files": 1, "rules": 1, "errors": [], "active": true}
}

The same summary is stored with each scan report, so a report can state exactly which custom rules were in force.

Writing rules​

SAST​

Standard Semgrep syntax: a top-level rules: list; each rule needs id, languages, message, severity (ERROR / WARNING / INFO, or CRITICAL / HIGH / MEDIUM / LOW) and a pattern, patterns, pattern-either or pattern-regex — or, for mode: taint, pattern-sources and pattern-sinks.

rules:
- id: acme-no-raw-requests
languages: [python]
severity: WARNING
message: Use acme.http.client instead of requests — it carries auth, retries and audit logging.
pattern: requests.$METHOD(...)
metadata: { category: security, owner: platform-security }

Findings from custom rules appear under SAST with the rule id as the identifier, and can be triaged like any other.

Secrets​

One file, named exactly gitleaks.toml, in Gitleaks configuration format. The platform merges it with the built-in rules and with the repository's own .gitleaks.toml when one exists, so list only what you add:

[extend]
useDefault = true # keep the built-in rules (assumed if omitted)
# disabledRules = ["generic-api-key"]

[[rules]]
id = "acme-service-token"
description = "Acme internal service token"
regex = '''\bacme_[a-z]{3}_[A-Za-z0-9]{32}\b'''
keywords = ["acme_"]

[[allowlists]]
description = "Test fixtures"
paths = ['''tests/fixtures/.*''']

Two things Gitleaks is strict about: write [extend] as a table header (not extend.useDefault = true), and remember that a regex with capture groups reports the first group as the secret unless secretGroup says otherwise. A repository whose own .gitleaks.toml already extends another file keeps that configuration as is — Gitleaks caps the extend chain at two levels, and the repository's committed configuration wins.

IaC​

Checkov YAML policies: metadata (id, name, category, severity) plus a definition over the resource attributes.

metadata:
id: "CKV2_ACME_1"
name: "S3 buckets carry the cost-centre tag"
category: "GENERAL_SECURITY"
severity: "MEDIUM"
definition:
cond_type: "attribute"
resource_types: ["aws_s3_bucket"]
attribute: "tags.cost_centre"
operator: "exists"