Overview
Every major ecosystem ships a first-party security linter that runs inside the toolchain developers already use - no scanner server, no new binary in the inner loop. This family trades depth for adoption cost: rules are single-file and pattern-based, but the feedback lands in the editor and on every commit, which is where shallow bugs (hardcoded creds, shell=True, innerHTML =, dynamic SOQL) actually get fixed.
| Language | Tool | Reference |
|---|---|---|
| Python | Bandit (PyCQA) | references/bandit.md |
| Go | gosec (securego), via golangci-lint | references/gosec.md |
| JS / TS | eslint-plugin-security + eslint-plugin-no-unsanitized | references/eslint-security.md |
| Salesforce Apex | PMD category/apex/security.xml | references/pmd-apex.md |
All four follow the same adoption pattern (below). The worked example uses the ESLint pair - the biggest audience; the per-tool references carry the full flag/rule catalogs, verified against each tool's docs.
Differentiation: these linters are single-file and (mostly) syntactic. For cross-language pattern rules use semgrep-rules; for cross-file taint tracking use codeql-queries; to merge their findings with other scanners into one gate use multi-tool-finding-triage.
When to use
The pattern
Each tool's exact commands live in its reference; the workflow is shared.
Step 1 - Install as a dev dependency
The linter installs through the ecosystem's own package manager - it versions, caches, and updates like any other dev dependency:
pip install bandit[toml] # Python
go install github.com/securego/gosec/v2/cmd/gosec@latest # Go
npm install --save-dev eslint-plugin-security eslint-plugin-no-unsanitized # JS/TS
# Apex: PMD zip / Docker image (Java 8+) - see references/pmd-apex.mdStep 2 - First scan, then filter the noise
Run the recursive scan, then immediately narrow with the tool's severity/confidence filters - every tool in this family is noisy at default settings, and an unfiltered first run is how teams end up disabling the linter:
bandit -r . --severity-level=medium --confidence-level=medium
gosec -severity=high -confidence=high ./...
npx eslint "src/**/*.{js,ts}" # rules pre-scoped by the shared config
pmd check -d . -R category/apex/security.xml --minimum-priority 2Step 3 - Baseline-diff adoption for legacy code
On a legacy codebase, capture the current findings once and gate only on NEW findings, so the debt is tracked without blocking every PR:
Step 4 - Suppression with justification (MANDATORY)
Every tool has an inline suppression syntax; every suppression carries a reason, reviewer, and expiry, audited quarterly:
| Tool | Inline syntax |
|---|---|
| Bandit | # nosec B602 (always with the rule ID) |
| gosec | // #nosec G401 -- Reason: ... |
| ESLint | // eslint-disable-next-line security/detect-object-injection + reason comment |
| PMD Apex | @SuppressWarnings('PMD.ApexCRUDViolation') + reason comment |
Bare suppressions (no rule ID, no reason) are unreviewed debt - grep for them in the quarterly audit. Categorical noise (a rule that can never apply) belongs in the tool's config file, not scattered inline.
Step 5 - CI wiring: SARIF + exit-code gate
All four tools emit SARIF for GitHub Code Scanning and gate CI via exit code. The shape is identical per tool - scan, upload SARIF if: always(), let the exit code block the PR:
- run: bandit -r . -f sarif -o bandit.sarif # or gosec / eslint / pmd
- uses: github/codeql-action/upload-sarif@v3
if: always()
with: { sarif_file: bandit.sarif }JSON output from each tool feeds multi-tool-finding-triage for the cross-scanner dedupe + waiver gate.
Worked example - ESLint security pair (JS/TS)
Per github.com/eslint-community/eslint-plugin-security (opens in new window) and github.com/mozilla/eslint-plugin-no-unsanitized (opens in new window):
// eslint.config.js (flat config, ESLint 8.23+ / 9+)
import pluginSecurity from "eslint-plugin-security";
import nounsanitized from "eslint-plugin-no-unsanitized";
export default [
pluginSecurity.configs.recommended, // all 14 detect-* rules
nounsanitized.configs.recommended, // method + property DOM-sink rules
];A scan of this code:
const userData = req.body;
element.innerHTML = userData.bio; // nounsanitized/property
const file = fs.readFileSync(req.query.path); // security/detect-non-literal-fs-filenamereports both findings; the safe rewrites are element.textContent = ... (or DOMPurify for rich HTML) and an allowlist check on the path. The highest-volume false positive is security/detect-object-injection (any obj[key] access) - triage it per Step 4 or downgrade it to warn in config. SARIF output uses the scoped formatter name in full:
npx eslint --format @microsoft/eslint-formatter-sarif \
--output-file eslint-security.sarif "src/**/*.{js,ts}"ESLint exit codes per eslint.org/docs/latest/use/command-line-interface (opens in new window): 0 clean, 1 errors (the gate), 2 config error. Full rule tables, the legacy-config variant, and the complete two-pass CI workflow (JSON for the triager + SARIF for Code Scanning) are in references/eslint-security.md.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Unfiltered first scan on a legacy repo | Noise overwhelms; team disables the linter | Severity/confidence filters (Step 2) + baseline (Step 3) |
| Bare suppression without rule ID | Suppresses ALL rules on that line | Always name the rule (Step 4) |
| Suppression without reason + expiry | Permanent unreviewed debt | Justification template, quarterly audit (Step 4) |
| Running the linter only in scheduled CI | Loses the in-editor shift-left benefit | Dev-dependency install + pre-commit (Step 1) |
| Treating linter pass as full SAST coverage | Single-file, mostly syntactic analysis | Layer semgrep-rules / codeql-queries |
Limitations
References
Per-tool reference for language-native-sast. Bandit is the Python-specific SAST originally from OpenStack Security.
Per bandit.readthedocs.io/en/latest/start.html (opens in new window):
Each finding has two dimensions:
The two-dimensional scoring lets you tune false-positive vs false-negative tradeoff per project.
Install
Per bd-start (opens in new window):
pip install bandit[toml]The [toml] extra enables pyproject.toml config support.
Basic recursive scan
Per bd-start (opens in new window):
bandit -r path/to/your/codeCommon usage:
bandit -r . # current dir, recursive
bandit -r src/ tests/ # multiple paths
bandit -r . -x tests,vendor # exclude dirs
bandit -r . -ll # minimum LOW confidence + LOW severitySeverity + confidence filtering
Per bd-start (opens in new window) verbatim CLI usage:
bandit examples/*.py -n 3 --severity-level=highCombined two-dimensional filtering:
# Only HIGH severity findings with HIGH confidence
bandit -r . --severity-level=high --confidence-level=high
# All MEDIUM+ severity, any confidence
bandit -r . --severity-level=mediumThe two flags compose; neither is a strict subset of the other.
pyproject.toml config
# pyproject.toml
[tool.bandit]
exclude_dirs = ["tests", "vendor", "build"]
skips = ["B101"] # skip "assert used" rule globally
tests = ["B201", "B301"] # only run flask + pickle checks (whitelist mode)
[tool.bandit.assert_used]
skips = ["**/test_*.py", "**/*_test.py"]tests = [...] activates whitelist mode (run ONLY listed checks); skips = [...] activates blacklist mode (run all checks except listed). They're mutually exclusive.
Rule ID catalog
Bandit rules are organized by category prefix:
| Prefix | Category | Examples |
|---|---|---|
| B1xx | Miscellaneous | B101 assert used, B102 exec used, B105 hardcoded password string |
| B2xx | Application/Framework | B201 flask debug=True, B202 tarfile unsafe extract |
| B3xx | Blacklists / Cryptography | B301 pickle, B303 MD5, B311 random for crypto, B321 ftplib (cleartext), B324 hashlib weak hash, B403 import_pickle |
| B4xx | Imports | B401 import_telnetlib, B404 subprocess imported, B405 import_xml_etree, B413 import_pyCrypto |
| B5xx | (less common - varies) | |
| B6xx | Injections | B602 subprocess shell=True, B603 subprocess without shell=False, B608 sql_injection, B610 django extra used (sql injection-prone) |
| B7xx | XSS / templating | B701 jinja2 autoescape false, B703 django mark_safe |
Full catalog: bandit.readthedocs.io/en/latest/plugins/.
False-positive triage (MANDATORY)
Per the canonical Bandit workflow, the suppression layers:
| Mechanism | Example | When to use |
|---|---|---|
Per-line # nosec | subprocess.run(cmd, shell=True) # nosec B602 | Single-line exception with rule ID |
Per-rule # nosec | # nosec B404 (above import statement) | Rule-specific suppression |
[tool.bandit] skips = ["..."] | Per-project rule disable | Categorical disable (test fixtures, etc.) |
[tool.bandit] exclude_dirs = ["..."] | Per-directory exclude | Generated code, vendored libs |
Justification template (mandatory in code):
import subprocess
# nosec B602 - Reason: command is statically defined, no user input
# Reviewer: alice@example.com (2026-05-15)
# Expires: 2026-12-15
result = subprocess.run("ls -la /tmp", shell=True, check=True)For rules that can never apply (e.g., B311 random is fine outside crypto contexts), prefer per-rule disable in pyproject.toml over per-line suppressions - fewer comments to maintain, easier to audit at the project level.
Cadence: every quarter, grep for # nosec patterns lacking # Reason: lines; flag for review.
Output formats
bandit -r . -f txt # default human-readable
bandit -r . -f json -o bandit.json # JSON for finding triage
bandit -r . -f sarif -o bandit.sarif # SARIF for GitHub Code Scanning
bandit -r . -f xml -o bandit.xml # JUnit XML
bandit -r . -f html -o bandit.html # standalone HTML report
bandit -r . -f csv -o bandit.csv # CSV
bandit -r . -f screen # colorized terminalBaseline for legacy adoption
Capture current findings once, then gate only on new ones:
bandit -r . -f json -o old-findings.json # capture
bandit -r . --baseline old-findings.json # CI: only NEW findings failPre-commit integration
# .pre-commit-config.yaml
repos:
- repo: https://github.com/PyCQA/bandit
rev: 1.7.10
hooks:
- id: bandit
args: ["--severity-level=medium", "--confidence-level=medium"]
files: \.py$
exclude: ^(tests/|venv/|.venv/)CI integration
jobs:
bandit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with: { python-version: '3.13' }
- run: pip install bandit[toml]
- run: bandit -r . -f sarif -o bandit.sarif
- uses: github/codeql-action/upload-sarif@v3
if: always()
with: { sarif_file: bandit.sarif }For PR-blocking: pipe through --severity-level high to limit noise.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
--severity-level=low everywhere | Noise overwhelms; team disables | Start --severity-level=medium; ratchet down |
# nosec without rule ID | Suppresses ALL rules on that line; over-broad | # nosec B602 (specific rule) |
Skip --confidence-level filter | LOW confidence findings mostly false positives | Pair --severity-level=high --confidence-level=medium for triage |
| Run on tests directory | Test-only patterns (assert, pickle) trigger noise | exclude_dirs = ["tests"] |
| No baseline; every legacy finding blocks CI | Team disables Bandit | Use --baseline old-findings.json against a captured baseline |
Limitations
Sources
Per-tool reference for language-native-sast. Two npm plugins add a security lint layer inside the standard ESLint pipeline, no external server required. They emit JSON or SARIF for a multi-scanner triage step.
Per github.com/eslint-community/eslint-plugin-security (opens in new window):
"This project will help identify potential security hotspots, but finds a lot of false positives which need triage by a human."
Per github.com/mozilla/eslint-plugin-no-unsanitized (opens in new window), the no-unsanitized plugin adds "basic security checks" for DOM sink operations (innerHTML, insertAdjacentHTML, document.write).
Install
npm install --save-dev eslint-plugin-security
npm install --save-dev eslint-plugin-no-unsanitizedFor SARIF output, per github.com/microsoft/sarif-js-sdk (opens in new window):
npm install --save-dev @microsoft/eslint-formatter-sarifFlat config setup (ESLint 8.23+ and ESLint 9+)
Per esp-sec (opens in new window) and esp-xss (opens in new window):
// eslint.config.js
import pluginSecurity from "eslint-plugin-security";
import nounsanitized from "eslint-plugin-no-unsanitized";
export default [
pluginSecurity.configs.recommended,
nounsanitized.configs.recommended,
];For legacy .eslintrc configs, per esp-sec (opens in new window):
module.exports = {
extends: ["plugin:security/recommended-legacy"],
};Rule catalog
Per esp-sec (opens in new window), configs.recommended enables all 14 detect-* rules:
| Rule ID | Detects |
|---|---|
security/detect-bidi-characters | Unicode bidi override characters (trojan-source attacks) |
security/detect-buffer-noassert | Buffer calls with the noAssert flag set |
security/detect-child-process | child_process use and non-literal exec() calls |
security/detect-disable-mustache-escape | Template engines with escaping disabled |
security/detect-eval-with-expression | eval(variable) - arbitrary code execution |
security/detect-new-buffer | new Buffer(non-literal) - deprecated unsafe API |
security/detect-no-csrf-before-method-override | Express middleware ordering that bypasses CSRF |
security/detect-non-literal-fs-filename | fs calls with variable filenames - path traversal |
security/detect-non-literal-regexp | RegExp(variable) - potential ReDoS |
security/detect-non-literal-require | require(variable) - dynamic require |
security/detect-object-injection | obj[variable] property access - prototype injection |
security/detect-possible-timing-attacks | Insecure string comparisons (==, ===) for secrets |
security/detect-pseudoRandomBytes | crypto.pseudoRandomBytes and Math.random for security |
security/detect-unsafe-regex | ReDoS-vulnerable regular expressions |
Per esp-xss (opens in new window), configs.recommended enables:
| Rule ID | Detects |
|---|---|
nounsanitized/method | Unsafe calls: insertAdjacentHTML, document.write, document.writeln with variable arguments |
nounsanitized/property | Unsafe assignments: element.innerHTML = variable, element.outerHTML = variable |
Safe alternatives per esp-xss (opens in new window): construct DOM nodes with createElement and set textContent or classList rather than assigning raw HTML strings.
False-positive triage
security/detect-object-injection is the highest-volume false positive: any obj[key] access triggers it, including safe patterns like array indexing. Standard triage approaches:
Per-line suppression with mandatory justification:
// eslint-disable-next-line security/detect-object-injection
// Reason: key is validated against allowedKeys before this point
// Reviewer: eng@example.com (2026-06-04)
// Expires: 2026-12-04
const value = config[key];Block-level suppression for generated or vendored code:
/* eslint-disable security/detect-object-injection */
// Reason: auto-generated lookup table; keys are compile-time constants
/* eslint-enable security/detect-object-injection */Rule-level severity downgrade when a rule produces only noise on a specific codebase:
// eslint.config.js
export default [
pluginSecurity.configs.recommended,
{
rules: {
"security/detect-object-injection": "warn", // downgrade from error
},
},
];Suppression cadence: audit all eslint-disable comments quarterly. Suppressions without Reason: + Reviewer: + Expires: are treated as unreviewed debt.
SARIF output for GitHub Code Scanning
Per sarif-sdk (opens in new window), the @microsoft/eslint-formatter-sarif package cannot be invoked with the abbreviated -f sarif form because its name is scoped. Use the full package name:
npx eslint \
--format @microsoft/eslint-formatter-sarif \
--output-file eslint-security.sarif \
"src/**/*.{js,ts}"To embed analyzed source content in the SARIF output:
SARIF_ESLINT_EMBED=true npx eslint \
--format @microsoft/eslint-formatter-sarif \
--output-file eslint-security.sarif \
"src/**/*.{js,ts}"JSON output for multi-scanner triage
Per eslint.org/docs/latest/use/command-line-interface (opens in new window), --format json produces an array of file result objects, each with a messages array containing ruleId, severity, line, column, and message. Feed eslint-security.json into a multi-scanner triage step alongside semgrep.json and other scanner outputs; normalize ruleId to CWE for deduplication across scanners.
CI integration with gating
Per eslint-cli (opens in new window), ESLint exit codes: 0 = no errors; 1 = errors found; 2 = config error. Gate CI on exit code 1:
# .github/workflows/security-lint.yml
name: Security Lint
on: [push, pull_request]
jobs:
eslint-security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "22" }
- run: npm ci
- name: Run security lint (JSON for triager)
run: |
npx eslint \
--format json \
--output-file eslint-security.json \
"src/**/*.{js,ts}" || true
- name: Run security lint (SARIF for Code Scanning)
run: |
npx eslint \
--format @microsoft/eslint-formatter-sarif \
--output-file eslint-security.sarif \
"src/**/*.{js,ts}"; exit $?
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: eslint-security.sarifThe JSON pass uses || true so SARIF upload still runs on failure; the SARIF pass propagates the real exit code so the job fails on errors.
Example
Triggering finding:
const userData = req.body;
element.innerHTML = userData.bio; // nounsanitized/property
const file = fs.readFileSync(req.query.path); // security/detect-non-literal-fs-filenameESLint output (stylish):
src/profile.js
12:3 error Unsafe assignment to innerHTML nounsanitized/property
18:3 error Found non-literal argument to readFileSync security/detect-non-literal-fs-filenameSafe rewrites:
// XSS: use textContent for plain text; DOMPurify for rich HTML
element.textContent = userData.bio;
// or: element.setHTML(sanitize(userData.bio));
// Path traversal: validate against an allowlist
const allowed = ["/var/data/a.txt", "/var/data/b.txt"];
if (!allowed.includes(req.query.path)) throw new Error("invalid path");
const file = fs.readFileSync(req.query.path);Limitations
Sources
Per-tool reference for language-native-sast.
Per github.com/securego/gosec (opens in new window):
gosec is the Go-specific SAST. It "performs static code analysis by scanning the Go AST and SSA code representation" and supports "taint analysis tracking data flow from user inputs to dangerous functions" per gs-gh (opens in new window). The taint analysis distinguishes gosec from regex-based linters - it tracks input flow through method chains, which catches injection patterns linters miss.
Install
Per gs-gh (opens in new window):
go install github.com/securego/gosec/v2/cmd/gosec@latestFor pinned versions in CI:
go install github.com/securego/gosec/v2/cmd/gosec@v2.20.0Docker:
docker pull securego/gosec
docker run --rm -v "$PWD:/code" securego/gosec ./code/...Basic recursive scan
Per gs-gh (opens in new window):
gosec ./...Common variations:
gosec -severity=high ./... # only HIGH severity
gosec -confidence=high ./... # only HIGH confidence
gosec -exclude=G104 ./... # skip "unhandled errors" rule
gosec -include=G101,G102 ./... # only run specific rulesRule ID catalog
Per gs-gh (opens in new window) the common rule IDs; the authoritative current list is emitted at runtime by gosec -list-rules:
| Rule | Description |
|---|---|
| G101 | Hardcoded credentials |
| G102 | Bind to all interfaces (0.0.0.0) |
| G103 | Audit unsafe block (use of unsafe package) |
| G104 | Unhandled errors |
| G106 | SSH InsecureIgnoreHostKey |
| G107 | URL with potential SSRF |
| G201 | SQL query construction by string concat |
| G202 | SQL query construction by string format |
| G204 | Subprocess launched with variable |
| G301 | Poor file permissions on directory |
| G302 | Poor file permissions on file |
| G303 | Predictable temp-file name |
| G304 | File path traversal vulnerabilities |
| G305 | File traversal in tar archive |
| G401 | Weak cryptographic algorithms |
| G402 | TLS InsecureSkipVerify |
| G403 | RSA key length too short |
| G404 | Insecure random number generation |
| G501-G505 | Insecure crypto primitives (DES, MD5, RC4, SHA1) |
| G601 | Implicit memory aliasing in for-range |
| G602 | Slice bounds out of range |
Prefix families at a glance: G1xx credential / injection / unsafe surface, G2xx SQL and subprocess construction, G3xx file and path handling, G4xx crypto and TLS misuse, G5xx insecure crypto primitives, G6xx Go memory and slice hazards.
Output formats
Per gs-gh (opens in new window):
gosec -fmt sarif -out results.sarif ./...
gosec -fmt json -out results.json ./...
gosec -fmt junit-xml -out results.xml ./...
gosec -fmt html -out results.html ./...
gosec -fmt text -out results.txt ./...
gosec -fmt yaml -out results.yaml ./...For multi-scanner triage integration, use JSON.
False-positive triage (MANDATORY)
Per gs-gh (opens in new window) the canonical inline suppression syntax:
// #nosec G404 -- justification textFormat: #nosec [RuleList] [-- Justification].
| Mechanism | Example | When to use |
|---|---|---|
Per-line #nosec | // #nosec G101 -- test fixture; not deployed to prod | Single-line exception with justification |
Per-rule list #nosec | // #nosec G104,G115 -- intentional in this fast-path | Multi-rule single-line |
-exclude= flag | gosec -exclude=G104 ./... | Project-wide rule disable (CI flag) |
-confidence= filter | gosec -confidence=high ./... | Triage workflow: only high-confidence first |
Justification template (mandatory in code):
// #nosec G401 -- Reason: legacy MD5 required for vendor-mandated checksum format
// Reviewer: alice@example.com (2026-05-15)
// Expires: 2026-12-15
hash := md5.Sum(data)Cadence: every quarter, grep for #nosec patterns lacking -- Reason: and flag for review.
golangci-lint integration
Most Go teams run gosec via golangci-lint (the universal linter runner) rather than directly:
# .golangci.yml
linters:
enable:
- gosec
linters-settings:
gosec:
excludes:
- G104 # unhandled errors (often noise)
severity: medium
confidence: medium
config:
G306: "0644" # default file perm thresholdgolangci-lint run ./...This is the recommended pattern - golangci-lint handles parallelism, caching, and unified output across multiple linters.
CI integration
Standalone:
jobs:
gosec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v5
with: { go-version: '1.22' }
- uses: securego/gosec@master
with:
args: -fmt sarif -out gosec.sarif ./...
- uses: github/codeql-action/upload-sarif@v3
if: always()
with: { sarif_file: gosec.sarif }Via golangci-lint (preferred):
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v5
with: { go-version: '1.22' }
- uses: golangci/golangci-lint-action@v6
with:
version: latestCustom rules
Unlike Semgrep / CodeQL, gosec doesn't have a custom-rule DSL - adding new rules requires writing Go code in gosec/rules/ and contributing upstream OR forking. For most teams, leverage the 40+ built-in rules + suppressions.
Worked example
A Go microservice needs to clear a security review. Run gosec -severity=high -confidence=high ./...; gosec reports a G401 on a md5.Sum(data) call and a G104 on an unchecked w.Write return.
Re-run gosec -fmt sarif -out gosec.sarif ./...; the SARIF now reports zero HIGH findings, and the golangci-lint gate passes in CI.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
#nosec without rule ID | Suppresses ALL rules on that line | // #nosec G401 (specific) |
#nosec without -- Justification | No audit trail | Required template |
Skip -confidence= filter | LOW confidence drowns the team | -confidence=high for triage |
| Run gosec separately from golangci-lint | Two linters with different config | golangci-lint integration |
| Exclude G104 globally | Loses entire "unhandled errors" coverage | Per-call // #nosec G104 -- intentional |
Limitations
Sources
PMD Apex security ruleset - Salesforce language-native SAST
View source (opens in a new tab)Per-tool reference for language-native-sast.
Per pmd.github.io - Apex Security Rules (opens in new window), PMD ships a built-in category/apex/security.xml ruleset that covers 10 security rules for Salesforce Apex. All 10 rules are present since PMD 5.5.3 and carry Medium (3) priority by default. The ruleset addresses the Salesforce-specific threat model: SOQL injection, object/field-level security bypass, sharing-model evasion, hard-coded credentials, insecure endpoints, XSS through Visualforce, and open redirect.
Install
Per pmd.github.io - Installation (opens in new window), PMD requires Java 8 or later. Download the zip from the GitHub releases page (opens in new window), unzip, and add bin/ to PATH:
# Linux / macOS
unzip pmd-dist-*.zip -d ~/pmd
export PATH="$HOME/pmd/bin:$PATH"
# Verify
pmd --version
For CI, prefer the Docker image or the Maven/Gradle plugin to avoid zip management. The Docker image is available at ghcr.io/pmd/pmd.
First scan with the built-in security ruleset
Per pmd.github.io - CLI Reference (opens in new window):
pmd check -d . -R category/apex/security.xml -f sarif -r pmd-apex.sarif
Flag reference (per pmd-cli (opens in new window)):
| Flag | Meaning |
|---|---|
-d <path> | Source directory or file to analyze |
-R <refs> | Ruleset path; comma-separated for multiple |
-f <format> | Output format (sarif, text, xml, json, html; default: text) |
-r <file> | Write report to file instead of stdout |
--minimum-priority <n> | Skip rules below priority n (1=High, 5=Info) |
--cache <file> | Enable incremental analysis (per pmd-cache (opens in new window)) |
Exit codes (per pmd-cli (opens in new window)):
| Code | Meaning |
|---|---|
| 0 | Success, no violations |
| 1 | Unhandled exception |
| 2 | Invalid arguments |
| 4 | Violations detected |
| 5 | Recoverable parsing errors |
The 10 Apex security rules
Per pmd-apex-sec (opens in new window):
| Rule | What it detects |
|---|---|
ApexSOQLInjection | Dynamic SOQL/DML built by string concatenation with untrusted input |
ApexCRUDViolation | Missing object/field permission check before SOQL, SOSL, or DML |
ApexSharingViolations | Classes performing DML without an explicit sharing keyword |
ApexBadCrypto | Hard-coded IVs or keys in cryptographic operations |
ApexDangerousMethods | Calls to Configuration.disableTriggerCRUDSecurity() or sensitive System.debug() |
ApexInsecureEndpoint | Plain HTTP (non-HTTPS) callout endpoints |
ApexOpenRedirect | Redirects using unsanitized user-controlled input |
ApexSuggestUsingNamedCred | Hard-coded credentials in HTTP headers; suggests Named Credentials |
ApexXSSFromEscapeFalse | addError() called with escape disabled, exposing raw user content |
ApexXSSFromURLParam | URL parameters used in output contexts without escaping |
ApexSOQLInjection
Per pmd-apex-sec (opens in new window): "Detects the usage of untrusted / unescaped variables in DML queries."
Non-compliant:
public class Foo {
public void test1(String t1) {
Database.query('SELECT Id FROM Account' + t1);
}
}
Compliant (bind variable - automatically sanitized by the Apex runtime):
public class Foo {
public void test1(String accountName) {
List<Account> accounts = [SELECT Id FROM Account WHERE Name = :accountName];
}
}
ApexCRUDViolation
Per pmd-apex-sec (opens in new window): "The rule validates you are checking for access permissions before a SOQL/SOSL/DML operation." Accepted remediation paths include DescribeSObjectResult system checks, WITH SECURITY_ENFORCED, or (since Winter '23 / API v56) WITH USER_MODE.
The rule is configurable for custom authorization facades via regex properties (createAuthMethodPattern, readAuthMethodPattern, etc.) so teams using an internal ESAPI wrapper can still pass the check.
ApexSharingViolations
Per pmd-apex-sec (opens in new window): "Detect classes declared without explicit sharing mode if DML methods are used." The three accepted keywords are with sharing, without sharing, and inherited sharing. The intent is to force a conscious declaration of sharing posture, not to mandate a specific value.
Custom ruleset (subset or extended)
Per pmd.github.io - Making Rulesets (opens in new window):
Use a custom XML ruleset to select a subset, override priorities, or add exclusion patterns for generated code:
<?xml version="1.0"?>
<ruleset name="Apex Security - Regulated"
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0
https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
<description>Apex security rules for regulated-industry Apex</description>
<!-- Include the full security category -->
<rule ref="category/apex/security.xml">
<!-- Suppress for auto-generated WSDL stubs -->
<exclude name="ApexSuggestUsingNamedCred"/>
</rule>
<!-- Exclude generated code directories -->
<exclude-pattern>.*/generated/.*</exclude-pattern>
</ruleset>
Run with the custom ruleset:
pmd check -d force-app/main/default/classes \
-R config/pmd-apex-regulated.xml \
-f sarif \
-r pmd-apex.sarif
Per pmd-rulesets (opens in new window), referencing an entire category means the ruleset automatically picks up new rules added to that category in future PMD versions. Pin specific versions in CI to avoid unexpected gate changes.
Incremental analysis for faster CI
Per pmd-cache (opens in new window) (PMD 5.6.0+):
pmd check -d force-app/main/default/classes \
-R category/apex/security.xml \
-f sarif \
-r pmd-apex.sarif \
--cache .pmd-cache/apex.cache
The cache stores file checksums. Unchanged files reuse cached results; only modified files are re-analyzed. The generated report is identical to a full run (per pmd-cache (opens in new window)). Cache is invalidated automatically on PMD version change, ruleset modification, or auxclasspath change.
CI gate (GitHub Actions)
jobs:
pmd-apex:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download PMD
run: |
PMD_VERSION=7.7.0
curl -Lo pmd.zip \
https://github.com/pmd/pmd/releases/download/pmd_releases%2F${PMD_VERSION}/pmd-dist-${PMD_VERSION}-bin.zip
unzip -q pmd.zip -d pmd-dist
echo "$PWD/pmd-dist/pmd-bin-${PMD_VERSION}/bin" >> $GITHUB_PATH
- name: Run PMD Apex security scan
run: |
pmd check \
-d force-app/main/default/classes \
-R category/apex/security.xml \
-f sarif \
-r pmd-apex.sarif \
--cache .pmd-cache/apex.cache
# Exit code 4 = violations found (per pmd-cli); gate blocks on non-zero
continue-on-error: false
- name: Upload SARIF to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: pmd-apex.sarif
category: pmd-apex
Per pmd-cli (opens in new window), exit code 4 means violations detected. continue-on-error: false blocks the PR on any finding. Use --minimum-priority 2 to gate only on High and Critical findings while still uploading all findings via SARIF.
Suppression
PMD suppression in Apex (per pmd-apex-sec (opens in new window)): annotate the method or class with @SuppressWarnings:
@SuppressWarnings('PMD.ApexCRUDViolation')
public class VisualforceGetter {
// Visualforce getters auto-enforce FLS; CRUD check is redundant here
public List<Account> getAccounts() {
return [SELECT Id, Name FROM Account];
}
}
Add a comment explaining the justification. Suppressions without rationale are flagged in code review.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Running against all directories including classes/test | Test classes generate false positives for CRUD/sharing | Exclude test directories with <exclude-pattern> |
No --cache in CI on large orgs | Full re-scan of 500+ classes on every commit is slow | Add --cache .pmd-cache/apex.cache |
Suppressing ApexSOQLInjection globally | Masks real injection risks | Suppress per method with a written justification only |
Floating latest PMD version in CI | Gate breaks when a new rule fires unexpectedly | Pin the PMD_VERSION variable |
Custom auth facade without *AuthMethodPattern config | All CRUD-checked methods still flagged as violations | Configure createAuthMethodPattern etc. per pmd-apex-sec (opens in new window) |