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.

/plugin install qa-security-scanning@testland-qa

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.

LanguageToolReference
PythonBandit (PyCQA)references/bandit.md
Gogosec (securego), via golangci-lintreferences/gosec.md
JS / TSeslint-plugin-security + eslint-plugin-no-unsanitizedreferences/eslint-security.md
Salesforce ApexPMD category/apex/security.xmlreferences/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 repo is Python, Go, JS/TS, or Apex and the team wants shift-left security feedback in the editor and on every commit, not only in a scheduled CI scan.
  • CI needs a fast, PR-blocking security lint pass without adopting a scanner server or a new SaaS.
  • A legacy codebase is adopting security linting and pre-existing findings must not block every PR (baseline-diff pattern, Step 3).

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

Step 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 2

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

  • Bandit: bandit -r . -f json -o old-findings.json, then bandit -r . --baseline old-findings.json in CI.
  • The generic pattern for the other tools: run the scan on main, save the report, diff per-PR findings against it and fail only on additions (multi-tool-finding-triage implements this diff + waiver flow).
  • Re-baseline on a schedule; persisted findings need waiver entries.

Step 4 - Suppression with justification (MANDATORY)

Every tool has an inline suppression syntax; every suppression carries a reason, reviewer, and expiry, audited quarterly:

ToolInline 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-filename

reports 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-patternWhy it failsFix
Unfiltered first scan on a legacy repoNoise overwhelms; team disables the linterSeverity/confidence filters (Step 2) + baseline (Step 3)
Bare suppression without rule IDSuppresses ALL rules on that lineAlways name the rule (Step 4)
Suppression without reason + expiryPermanent unreviewed debtJustification template, quarterly audit (Step 4)
Running the linter only in scheduled CILoses the in-editor shift-left benefitDev-dependency install + pre-commit (Step 1)
Treating linter pass as full SAST coverageSingle-file, mostly syntactic analysisLayer semgrep-rules / codeql-queries

Limitations

  • Single-file analysis: none of the four track taint across module or package boundaries (gosec's SSA taint tracking is in-package). Use codeql-queries for interprocedural flows.
  • Rule depth varies by ecosystem era - Bandit is thinner on FastAPI/async patterns, gosec on generics, PMD Apex does not parse Lightning Web Components (use semgrep-rules for LWC JavaScript).
  • High-volume false-positive rules exist in each tool (detect-object-injection, G104, B101, ApexCRUDViolation on Visualforce getters) - the per-tool references name them and the sanctioned handling.

References

  • references/bandit.md - Bandit: rules, flags, pyproject config, pre-commit, baseline
  • references/gosec.md - gosec: G-rule catalog, golangci-lint integration, #nosec syntax
  • references/eslint-security.md - ESLint pair: 14+2 rule tables, SARIF formatter, CI workflow
  • references/pmd-apex.md - PMD Apex: 10-rule security category, custom rulesets, incremental cache
  • semgrep-rules, codeql-queries, sonarqube-rules - deeper / cross-language SAST siblings
  • multi-tool-finding-triage - cross-scanner dedupe + waiver gate
Reference

Bandit - Python language-native SAST

View source (opens in a new tab)

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:

  • Severity (LOW / MEDIUM / HIGH) - how dangerous if exploited
  • Confidence (LOW / MEDIUM / HIGH) - how certain Bandit is the finding is real

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/code

Common 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 severity

Severity + confidence filtering

Per bd-start (opens in new window) verbatim CLI usage:

bandit examples/*.py -n 3 --severity-level=high

Combined 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=medium

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

PrefixCategoryExamples
B1xxMiscellaneousB101 assert used, B102 exec used, B105 hardcoded password string
B2xxApplication/FrameworkB201 flask debug=True, B202 tarfile unsafe extract
B3xxBlacklists / CryptographyB301 pickle, B303 MD5, B311 random for crypto, B321 ftplib (cleartext), B324 hashlib weak hash, B403 import_pickle
B4xxImportsB401 import_telnetlib, B404 subprocess imported, B405 import_xml_etree, B413 import_pyCrypto
B5xx(less common - varies)
B6xxInjectionsB602 subprocess shell=True, B603 subprocess without shell=False, B608 sql_injection, B610 django extra used (sql injection-prone)
B7xxXSS / templatingB701 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:

MechanismExampleWhen to use
Per-line # nosecsubprocess.run(cmd, shell=True) # nosec B602Single-line exception with rule ID
Per-rule # nosec# nosec B404 (above import statement)Rule-specific suppression
[tool.bandit] skips = ["..."]Per-project rule disableCategorical disable (test fixtures, etc.)
[tool.bandit] exclude_dirs = ["..."]Per-directory excludeGenerated 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 terminal

Baseline 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 fail

Pre-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-patternWhy it failsFix
--severity-level=low everywhereNoise overwhelms; team disablesStart --severity-level=medium; ratchet down
# nosec without rule IDSuppresses ALL rules on that line; over-broad# nosec B602 (specific rule)
Skip --confidence-level filterLOW confidence findings mostly false positivesPair --severity-level=high --confidence-level=medium for triage
Run on tests directoryTest-only patterns (assert, pickle) trigger noiseexclude_dirs = ["tests"]
No baseline; every legacy finding blocks CITeam disables BanditUse --baseline old-findings.json against a captured baseline

Limitations

  • Python-only; for Go see gosec.md (opens in new window), for JS/TS see eslint-security.md (opens in new window).
  • Plugin-based detection misses some patterns that Semgrep custom rules can catch.
  • No native cross-file taint analysis (use codeql-queries for that).
  • Rule depth varies - well-maintained for OpenStack-era patterns; newer Python ecosystem (FastAPI, async patterns) coverage thinner.

Sources

  • bd-start (opens in new window) - install + basic scan reference
  • bandit.readthedocs.io - full documentation
  • bandit.readthedocs.io/en/latest/plugins/ - rule catalog
  • github.com/PyCQA/bandit - repository
Reference

ESLint security plugins - JS/TS language-native SAST

View source (opens in a new tab)

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

For SARIF output, per github.com/microsoft/sarif-js-sdk (opens in new window):

npm install --save-dev @microsoft/eslint-formatter-sarif

Flat 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 IDDetects
security/detect-bidi-charactersUnicode bidi override characters (trojan-source attacks)
security/detect-buffer-noassertBuffer calls with the noAssert flag set
security/detect-child-processchild_process use and non-literal exec() calls
security/detect-disable-mustache-escapeTemplate engines with escaping disabled
security/detect-eval-with-expressioneval(variable) - arbitrary code execution
security/detect-new-buffernew Buffer(non-literal) - deprecated unsafe API
security/detect-no-csrf-before-method-overrideExpress middleware ordering that bypasses CSRF
security/detect-non-literal-fs-filenamefs calls with variable filenames - path traversal
security/detect-non-literal-regexpRegExp(variable) - potential ReDoS
security/detect-non-literal-requirerequire(variable) - dynamic require
security/detect-object-injectionobj[variable] property access - prototype injection
security/detect-possible-timing-attacksInsecure string comparisons (==, ===) for secrets
security/detect-pseudoRandomBytescrypto.pseudoRandomBytes and Math.random for security
security/detect-unsafe-regexReDoS-vulnerable regular expressions

Per esp-xss (opens in new window), configs.recommended enables:

Rule IDDetects
nounsanitized/methodUnsafe calls: insertAdjacentHTML, document.write, document.writeln with variable arguments
nounsanitized/propertyUnsafe 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.sarif

The 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-filename

ESLint 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-filename

Safe 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

  • detect-object-injection fires on all variable-keyed property accesses; expect high false-positive volume on data-heavy code. Pair with a code review step rather than blocking CI on it alone.
  • Neither plugin performs cross-file taint tracking; for taint flow across module boundaries, pair with semgrep-rules or codeql-queries.
  • eslint-plugin-security does not cover server-side template injection or SQL injection natively; use Semgrep p/owasp-top-ten for those patterns.
  • Rules run at parse time, not at runtime; dynamic injection via eval called through a proxy chain will not be caught.

Sources

Reference

gosec - Go language-native SAST

View source (opens in a new tab)

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@latest

For pinned versions in CI:

go install github.com/securego/gosec/v2/cmd/gosec@v2.20.0

Docker:

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 rules

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

RuleDescription
G101Hardcoded credentials
G102Bind to all interfaces (0.0.0.0)
G103Audit unsafe block (use of unsafe package)
G104Unhandled errors
G106SSH InsecureIgnoreHostKey
G107URL with potential SSRF
G201SQL query construction by string concat
G202SQL query construction by string format
G204Subprocess launched with variable
G301Poor file permissions on directory
G302Poor file permissions on file
G303Predictable temp-file name
G304File path traversal vulnerabilities
G305File traversal in tar archive
G401Weak cryptographic algorithms
G402TLS InsecureSkipVerify
G403RSA key length too short
G404Insecure random number generation
G501-G505Insecure crypto primitives (DES, MD5, RC4, SHA1)
G601Implicit memory aliasing in for-range
G602Slice 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 text

Format: #nosec [RuleList] [-- Justification].

MechanismExampleWhen to use
Per-line #nosec// #nosec G101 -- test fixture; not deployed to prodSingle-line exception with justification
Per-rule list #nosec// #nosec G104,G115 -- intentional in this fast-pathMulti-rule single-line
-exclude= flaggosec -exclude=G104 ./...Project-wide rule disable (CI flag)
-confidence= filtergosec -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 threshold
golangci-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: latest

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

  • G401 is a false positive here - the MD5 is a vendor-mandated checksum, not a security hash. Suppress it with an audited justification:

    // #nosec G401 -- Reason: legacy MD5 required for vendor-mandated checksum format
    hash := md5.Sum(data)
    
  • G104 is a real bug - the unhandled w.Write error is fixed by checking its return value, not suppressed.

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-patternWhy it failsFix
#nosec without rule IDSuppresses ALL rules on that line// #nosec G401 (specific)
#nosec without -- JustificationNo audit trailRequired template
Skip -confidence= filterLOW confidence drowns the team-confidence=high for triage
Run gosec separately from golangci-lintTwo linters with different configgolangci-lint integration
Exclude G104 globallyLoses entire "unhandled errors" coveragePer-call // #nosec G104 -- intentional

Limitations

  • Go-only; for other languages see the sibling references.
  • Custom-rule authoring requires Go programming + upstream PR (vs YAML rule authoring in Semgrep).
  • Some patterns (cross-package taint flow) miss what codeql-queries catches.
  • Rule depth varies - newer Go patterns (generics, structured concurrency) coverage thinner.

Sources

  • gs-gh (opens in new window) - repository, install, rule list, suppression syntax
  • gosec subcommand gosec -list-rules - current rule catalog
  • golangci-lint.run - golangci-lint integration
  • securego/gosec GitHub Action - github.com/securego/gosec
Reference

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

FlagMeaning
-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)):

CodeMeaning
0Success, no violations
1Unhandled exception
2Invalid arguments
4Violations detected
5Recoverable parsing errors

The 10 Apex security rules

Per pmd-apex-sec (opens in new window):

RuleWhat it detects
ApexSOQLInjectionDynamic SOQL/DML built by string concatenation with untrusted input
ApexCRUDViolationMissing object/field permission check before SOQL, SOSL, or DML
ApexSharingViolationsClasses performing DML without an explicit sharing keyword
ApexBadCryptoHard-coded IVs or keys in cryptographic operations
ApexDangerousMethodsCalls to Configuration.disableTriggerCRUDSecurity() or sensitive System.debug()
ApexInsecureEndpointPlain HTTP (non-HTTPS) callout endpoints
ApexOpenRedirectRedirects using unsanitized user-controlled input
ApexSuggestUsingNamedCredHard-coded credentials in HTTP headers; suggests Named Credentials
ApexXSSFromEscapeFalseaddError() called with escape disabled, exposing raw user content
ApexXSSFromURLParamURL 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-patternWhy it failsFix
Running against all directories including classes/testTest classes generate false positives for CRUD/sharingExclude test directories with <exclude-pattern>
No --cache in CI on large orgsFull re-scan of 500+ classes on every commit is slowAdd --cache .pmd-cache/apex.cache
Suppressing ApexSOQLInjection globallyMasks real injection risksSuppress per method with a written justification only
Floating latest PMD version in CIGate breaks when a new rule fires unexpectedlyPin the PMD_VERSION variable
Custom auth facade without *AuthMethodPattern configAll CRUD-checked methods still flagged as violationsConfigure createAuthMethodPattern etc. per pmd-apex-sec (opens in new window)

Limitations

  • PMD Apex analysis is syntactic, not data-flow-based; it can miss taint paths that cross method boundaries. For deep interprocedural analysis pair with codeql-queries.
  • ApexCRUDViolation generates false positives on Visualforce getter methods where FLS is enforced automatically; suppress with justification.
  • PMD does not parse Lightning Web Components (.js); client-side XSS is out of scope. Use semgrep-rules with the p/owasp-top-ten ruleset for LWC JavaScript.

Sources