language-native-sast
Language-native SAST linters - the first-party "linter as SAST" family that runs inside each ecosystem's standard toolchain with no separate scanner server: Bandit (Python, 60+ B-rules, severity x confidence filtering), gosec (Go, 40+ G-rules, AST + SSA taint tracking, golangci-lint integration), eslint-plugin-security + eslint-plugin-no-unsanitized (JS/TS, 14 detect-* rules + DOM-sink XSS), and PMD's Apex security ruleset (Salesforce, ApexSOQLInjection / ApexCRUDViolation / ApexSharingViolations). Covers the shared adoption pattern - install as a dev dependency, first scan, suppression-with-justification discipline, baseline-diff adoption for legacy code, SARIF output + CI gating - with per-tool depth in references. Use when a repo needs in-toolchain security linting for Python, Go, JavaScript/TypeScript, or Apex; for cross-language or cross-file taint analysis use semgrep-rules / codeql-queries instead.
Install with skills.sh (any agent)
npx skills add testland/qa --skill language-native-sastlanguage-native-sast
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
Bandit - Python language-native SAST
View source (opens in new window)Bandit - Python language-native SAST
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
ESLint security plugins - JS/TS language-native SAST
View source (opens in new window)ESLint security plugins - JS/TS language-native SAST
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
gosec - Go language-native SAST
View source (opens in new window)gosec - Go language-native SAST
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 new window)PMD Apex security ruleset - Salesforce language-native SAST
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) |
Limitations
Sources
Related skills
codeql-queries
Configures and runs GitHub CodeQL - semantic-database SAST with queries written in the CodeQL declarative query language; supports `codeql database create` (per-language) + `codeql database analyze` with --format=sarif; ships query packs (`codeql/javascript-queries`, `codeql/python-queries`, `codeql/java-queries`, `codeql/go-queries`, etc.); integrates with GitHub Code Scanning via SARIF upload; suppression via inline comment + sarif-filter + Security-tab dismissal. Use when the team uses GitHub-hosted repos and needs deep semantic SAST beyond pattern matching (cross-file taint flows, dataflow analysis).
cve-exploitability-triage
Ranks known CVE findings by real-world exploitability instead of severity alone: enriches each CVE with its EPSS probability (the chance exploitation activity is observed in the next 30 days) and CISA KEV membership (confirmed exploited in the wild), applies OpenVEX status assertions to set aside vulnerabilities the product is not affected by, applies a reachability heuristic for vulnerable code that is never called, and assigns every finding to one of four buckets (Fix-Now, Fix-This-Sprint, Fix-Backlog, Accept-Risk) using documented EPSS thresholds. Treats a CISA KEV listing as non-waivable under any justification. Use when a dependency, container image, or SBOM vulnerability scan has produced more CVEs than the team can fix in the available window and someone has to decide which ones get fixed first and which can wait.
dependabot-config
Reference for `.github/dependabot.yml` - GitHub-native dependency-update orchestrator. Required keys (`version: 2`, `updates[]` array) plus per-update fields (`package-ecosystem`, `directory` / `directories`, `schedule.interval`); common optional fields (`ignore`, `groups`, `allow`, `labels`, `milestone`, `open-pull-requests-limit`, `target-branch`, `vendor`, `versioning-strategy`, `assignees`, `commit-message`); auto-rebase + grouped-PR + security-only updates. Use when authoring or reviewing Dependabot configs in GitHub-hosted repos.
gitleaks-scanning
Configures and runs gitleaks - Go-based secret scanner with `gitleaks git` (scan local git via `git log -p`), `gitleaks dir` (filesystem), `gitleaks stdin` (pipe); 100+ built-in rules + custom rules in `.gitleaks.toml` ([[rules]] with regex / entropy / keywords / tags); allowlist via [[rules.allowlists]] (commits / paths / stopwords); pre-commit hook + GitHub Action integration; plus baseline management for legacy debt - onboarding a repo with historical findings via `--baseline-path` snapshots, `.gitleaksignore`, cross-tool suppression consistency with TruffleHog, and rot-prevention cadence. Use when the team needs OSS secret scanning at commit time + CI gate, or is adopting scanning on a repo with pre-existing findings.
multi-tool-finding-triage
Merges two or more security scanner reports into one gate. Use when you need a single BLOCK or PASS decision from multiple scanners instead of reading N separate reports. Normalizes each report into one common finding format (a canonical `Finding`), deduplicates on a per-domain key while recording which scanners agree (`caught_by` consensus), validates a waiver (finding-suppression) file, rejecting any missing `expires:` / `approved_by:` / `reason:` or expired, enriches CVE findings with EPSS (exploit-probability) and CISA KEV (known-exploited catalog), then applies a `fail_on` severity threshold to emit BLOCK or PASS plus a bucketed pull-request comment. Works across static (SAST), dynamic (DAST), secret, dependency (SCA), container, and IaC scanners. To run a single scanner instead use semgrep-rules, codeql-queries, or one of the language-native-sast linters; this runs after them to merge output - the cross-scanner gate, not a single-scanner wrapper.
npm-pip-maven-audit
Configures and runs native package-manager audit commands across ecosystems - `npm audit --audit-level=high` (npm), `yarn npm audit` (Yarn 2+), `pnpm audit` (pnpm), `pip-audit` (Python via PyPA), `mvn dependency:check` (Maven via OWASP Dependency-Check plugin), `cargo audit` (Rust, with `.cargo/audit.toml` suppression, `--deny` semantics, SARIF, binary auditing, and the rustsec/audit-check Action as a reference), and `bundle audit` (Ruby Bundler, with `.bundler-audit.yml` waivers, Rake integration, and CI gating as a reference); fastest no-install-required SCA option. Use when the team wants fast, no-extra-tooling SCA in CI as a first line of defense, when a Rust or Ruby repo needs its ecosystem-native scanner, or pairs with snyk/osv-scanner for layered coverage.
nuclei-dast
Installs and runs ProjectDiscovery Nuclei template-based HTTP scanning: selects templates via `-t {path}` and `-tags`/`-severity` filters, controls request rate with `-rl`, emits JSONL output via `-j` for cross-tool finding aggregation, authors custom YAML matchers for app-specific checks, and gates CI on severity thresholds. Use when the team runs Nuclei alongside ZAP for template-driven DAST coverage, needs fuzzing-style probes beyond ZAP passive scan, or wants to operationalize community CVE templates in a pipeline.
osv-scanner
Configures and runs Google OSV-Scanner - open-source SCA against the OSV.dev vulnerability database; supports `osv-scanner scan -r ./` recursive scan + per-lockfile scan via `-L package-lock.json`; SBOM input (CycloneDX / SPDX) for non-standard package managers; `--format json|sarif|markdown|vertical|html` output; suppressions via `osv-scanner.toml` config. Use when the team needs OSS-native SCA without commercial-license overhead, or wants a second-opinion DB pair with Snyk's commercial DB.
reachability-analyzer
Runs dead-dependency analysis across JS, Python, and Rust projects using ecosystem-native static tools (`depcheck`/`knip` for JS, `vulture` for Python, `cargo-machete` for Rust), then cross-references the unused-dependency list against SCA findings to downrank vulns in code that is never loaded. Use when SCA output (from `osv-scanner`, `snyk-test`, or `npm-pip-maven-audit`) is too noisy to triage and the team needs to separate unreachable CVEs from exploitable ones before sprint planning; sibling cve-exploitability-triage ranks by EPSS/KEV exploitation signal, not code reachability.
renovate-config
Reference for `renovate.json` - Mend Renovate dependency-update orchestrator (multi-platform: GitHub / GitLab / Bitbucket / Azure DevOps / Gitea); top-level keys (`extends` for preset references, `schedule`, `prConcurrentLimit`, `vulnerabilityAlerts`); `packageRules[]` array with `matchPackageNames` / `matchUpdateTypes` / `automerge` matching; `ignoreDeps`, `addLabels`, `automergeSchedule`. Use when authoring or reviewing Renovate configs in any repo platform Renovate supports.
sbom-formats
Reference for the two SBOM specification families and how to choose between them - CycloneDX v1.6 (OWASP-curated, security-focused: components, services, dependencies, first-class vulnerabilities[] with embedded VEX, formulation, ML/SaaS BOMs; XML / JSON / Protobuf) as the primary format, with SPDX 2.3 + 3.0 (Linux Foundation, license-focused: packages, relationships, license expressions, Tag-Value/JSON encodings, ISO/IEC 5962:2021) covered as a reference. Includes per-language generators, schema validation, sign + attest CI wiring, and the format-choice guidance (CycloneDX for security-focused consumers; SPDX for US Federal procurement, Linux Foundation, and license-compliance contexts). Use when the user asks to write or validate an SBOM in CycloneDX or SPDX form, or the team must pick its SBOM format.
secrets-rotation-runner
Build-an-X for the secret-rotation workflow after detection - detect via gitleaks/trufflehog/kingfisher → identify provider via verifier → rotate via provider API (AWS IAM / GitHub PAT / Stripe / GCP / Azure / Twilio / Slack / etc.) → invalidate old secret → audit log via observability stack → post-mortem cross-ref. Use when a secret is detected in code (or proactively for periodic rotation) - assume git-history scrub does NOT prevent compromise.
semgrep-rules
Configures and runs Semgrep - pattern-based SAST across 30+ languages with the Semgrep Registry rulesets (`p/owasp-top-ten`, `p/default`, `auto`) plus custom YAML rules; integrates `semgrep ci` for PR-blocking gates with `--baseline-commit` diff-aware scanning, per-finding inline `nosemgrep` suppressions, `--exclude` / `--include` path filters, output formats (`--json` / `--sarif` / `--gitlab-sast` / `--junit-xml`), and severity filter (INFO/WARNING/ERROR). Use when the user runs Semgrep, asks about pattern rules, or needs a low-friction SAST gate without semantic-DB setup.
snyk-test
Configures and runs Snyk, a commercial multi-mode scanner: snyk test for SCA (dependency scanning), snyk code test for SAST (code security scanning), snyk container test for container images, snyk iac test for IaC (infrastructure-as-code), snyk monitor for continuous new-vuln alerts; policy file .snyk for ignore + patch. Use when the team has a Snyk license and needs SCA (dependency scanning) or continuous vuln monitoring; for open-source scanning without a Snyk license, prefer osv-scanner.
sonarqube-rules
Configures and runs SonarQube / SonarCloud - multi-language SAST + Quality Gate platform with built-in Sonar Way rule profiles + custom rule plugins; integrates `sonar-scanner` with `sonar-project.properties` config; supports Quality Gate definitions including new-code-period blocking, branch + PR analysis, and per-issue suppression via `// NOSONAR` comment or `@SuppressWarnings("squid:RULE_ID")` annotation. Use when the user runs SonarQube Community / Developer / Enterprise edition or SonarCloud, or needs a multi-language SAST + code-quality platform with persistent issue tracking.
syft-generation
Generates, scans, and diffs Software Bills of Materials (SBOMs) with the Anchore stack - Syft generation from container images / directories / archives across OCI / Docker / Singularity formats (output CycloneDX-JSON / SPDX-JSON / Syft-JSON / table / GitHub-JSON, cosign attestation); the paired generate + scan workflow with Grype (`grype sbom:./sbom.json`, `--fail-on high`, `--only-fixed`, `.grype.yaml` ignore rules with mandatory `expires:`, EPSS/KEV prioritization); and SBOM-to-SBOM diffing via `cyclonedx diff --component-versions` to gate CI on net-new components and detect supply-chain drift between builds. Use when the team needs SBOM artifacts for compliance (US EO 14028, EU CRA, FDA medical-device guidance), SBOM-driven vulnerability scanning, or dependency-drift detection between releases.
trivy-image
Configures and runs Trivy for container image scanning: Aqua Security's all-in-one scanner combining vuln + secret + misconfiguration + license detection in one pass; `trivy image {image}` with --severity HIGH,CRITICAL filter; --format sarif/json (incl. scan-embedded CycloneDX; for standalone SBOM generation see syft-generation + sbom-formats); .trivyignore CVE suppression file; --ignore-unfixed for actionable filter; --scanners vuln/misconfig/license/secret toggle. Use when the team wants a single tool covering container image security across multiple dimensions, not for producing a standalone CycloneDX SBOM.
trufflehog-scanning
Configures and runs TruffleHog v3 - secret scanner with **live verification** (validates discovered secrets against provider APIs to confirm actual exposure vs entropy false positive); supports per-source subcommands (`git`, `github`, `gitlab`, `filesystem`, `s3`, `docker`, `gcs`, `postman`); `--results=verified` filter for high-precision output; `--exclude-detectors=TYPE` for noise reduction; exits 183 on findings via `--fail`. Use when the team needs verified secret findings (low false-positive rate) or scans across cloud + repo + container surfaces.
vex-author
Authors and validates OpenVEX documents - produces `not_affected`, `affected`, `fixed`, and `under_investigation` statements with justification codes using `vexctl create`; attaches VEX assertions to container images; outputs `.openvex.json` files consumed on a downstream VEX-filter / vulnerability-prioritization path. Use when a scanner flags a CVE that analysis confirms is not exploitable in your deployment, and a machine-readable `not_affected` assertion is needed to suppress false positives without discarding the finding from the audit trail.
zap-baseline
Configures and runs OWASP ZAP baseline scanning: `zap-baseline.py` Docker-packaged spider + passive scan suitable for CI gating; supports `-t target_url` + `-r html_report` + `-c config_file` rule customization (INFO/IGNORE/FAIL warnings) and Ajax spider via `-j` for JS-heavy SPAs; `zap-full-scan.py` active companion for staging. Covers authenticated scans end to end as a reference - ZAP Context, auth methods (form/JSON/script/browser), session management, verification strategy, OAuth/bearer injection, context XML export for `-n` - plus DAST cadence planning (PR-blocking passive baseline, nightly ZAP full + nuclei active layer, baseline-finding ratchet for legacy apps). Use when the user runs OWASP ZAP for pre-prod web app DAST, needs coverage of routes behind a login wall, or is designing a team's DAST rollout cadence.