diff --git a/.custom-gcl.yml b/.custom-gcl.yml new file mode 100644 index 0000000..f62f9b0 --- /dev/null +++ b/.custom-gcl.yml @@ -0,0 +1,8 @@ +version: v2.11.2 +name: custom-golangci-lint +destination: ./bin + +plugins: + - module: github.com/rTexty/logsLinter + import: github.com/rTexty/logsLinter/plugin + path: . diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0c000c3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*.{go,md,yml,yaml}] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = tab +indent_size = 4 +trim_trailing_whitespace = true + +[*.{md,yml,yaml}] +indent_style = space +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..4df7a9d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @rTexty diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..79d4fe2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,27 @@ +name: Bug report +description: Report a defect or regression. +title: "bug: " +labels: + - bug +body: + - type: textarea + id: description + attributes: + label: Description + description: What is broken and what did you expect instead? + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: Minimal steps or sample code that reproduces the issue. + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Go version, OS, and any relevant tooling. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..11f8422 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security issue + url: https://github.com/rTexty/logsLinter/security/advisories/new + about: Report undisclosed vulnerabilities through private security reporting. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..7ab69b4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,25 @@ +name: Feature request +description: Propose a new capability or improvement. +title: "feat: " +labels: + - enhancement +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What problem are you trying to solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: Describe the desired behavior or API. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives + description: What alternatives did you consider? diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..9261d80 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,2 @@ +self-hosted-runner: + labels: [] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fb4a20a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies + - github-actions + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies + - github-actions diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..6890f91 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## Summary + +- + +## Verification + +- [ ] `go test ./...` +- [ ] `go build ./...` +- [ ] relevant docs updated +- [ ] changelog or release-notes impact considered + +## Checklist + +- [ ] no secrets or sensitive data added +- [ ] no unrelated files changed +- [ ] CI is expected to pass + +## Release Notes + +- Suggested label category: +- Changelog entry needed: yes / no diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..00d0a47 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,37 @@ +changelog: + exclude: + labels: + - skip-release-notes + - github-actions + + categories: + - title: Breaking Changes + labels: + - breaking-change + + - title: Features + labels: + - enhancement + - feature + + - title: Fixes + labels: + - bug + - fix + + - title: Security + labels: + - security + + - title: Documentation + labels: + - documentation + + - title: Maintenance + labels: + - chore + - dependencies + + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml new file mode 100644 index 0000000..c9d2e9f --- /dev/null +++ b/.github/workflows/actionlint.yml @@ -0,0 +1,24 @@ +name: Workflow Lint + +on: + push: + paths: + - ".github/workflows/**" + - ".github/actionlint.yaml" + pull_request: + paths: + - ".github/workflows/**" + - ".github/actionlint.yaml" + +permissions: + contents: read + +jobs: + actionlint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run actionlint + uses: raven-actions/actionlint@v2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b92ee98 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: + - main + - master + - feature/** + pull_request: + +permissions: + contents: read + +jobs: + build-test: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Verify formatting + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "Unformatted files:" + echo "$unformatted" + exit 1 + fi + + - name: Download modules + run: go mod download + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... -race -count=1 + + - name: Build + run: go build ./... + + - name: Lint repository code + uses: golangci/golangci-lint-action@v8 + with: + version: v2.1 + args: --config=.golangci.repo.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..373156e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: CodeQL + +on: + push: + branches: + - main + - master + pull_request: + schedule: + - cron: "17 3 * * 1" + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + runs-on: ubuntu-latest + timeout-minutes: 20 + + strategy: + fail-fast: false + matrix: + language: + - go + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..033c9a3 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,18 @@ +name: Dependency Review + +on: + pull_request: + +permissions: + contents: read + pull-requests: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Dependency review + uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fb89990 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,111 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.goos }}-${{ matrix.goarch }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + archive: tar.gz + - goos: linux + goarch: arm64 + archive: tar.gz + - goos: darwin + goarch: amd64 + archive: tar.gz + - goos: darwin + goarch: arm64 + archive: tar.gz + - goos: windows + goarch: amd64 + archive: zip + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run tests + run: go test ./... -race -count=1 + + - name: Build binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + mkdir -p dist + binary_name="logslinter" + if [ "$GOOS" = "windows" ]; then + binary_name="${binary_name}.exe" + fi + + output_dir="dist/logslinter_${GOOS}_${GOARCH}" + mkdir -p "$output_dir" + + go build -trimpath -ldflags="-s -w" -o "$output_dir/$binary_name" ./cmd/logslinter + + - name: Package tar.gz artifact + if: matrix.archive == 'tar.gz' + run: | + archive_name="logslinter_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz" + tar -C dist -czf "dist/$archive_name" "logslinter_${{ matrix.goos }}_${{ matrix.goarch }}" + + - name: Package zip artifact + if: matrix.archive == 'zip' + run: | + archive_name="logslinter_${{ matrix.goos }}_${{ matrix.goarch }}.zip" + cd dist + zip -r "$archive_name" "logslinter_${{ matrix.goos }}_${{ matrix.goarch }}" + + - name: Upload packaged artifact + uses: actions/upload-artifact@v4 + with: + name: logslinter-${{ matrix.goos }}-${{ matrix.goarch }} + path: | + dist/*.tar.gz + dist/*.zip + + release: + name: Publish release + runs-on: ubuntu-latest + needs: build + if: startsWith(github.ref, 'refs/tags/v') + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Generate checksums + run: | + cd dist + sha256sum *.tar.gz *.zip > SHA256SUMS.txt + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + dist/*.tar.gz + dist/*.zip + dist/SHA256SUMS.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bfebf1d --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Editor and OS noise +.DS_Store +.idea/ + +# Build outputs +bin/ +dist/ +coverage.out +*.test + +# Local custom golangci-lint binaries +custom-gcl +custom-golangci-lint + +# Local planning and context artifacts +.claude/ +CLAUDE.md +Context.md +Context/ +tech_spec.pdf diff --git a/.golangci.repo.yml b/.golangci.repo.yml new file mode 100644 index 0000000..cb59f19 --- /dev/null +++ b/.golangci.repo.yml @@ -0,0 +1,17 @@ +version: "2" + +linters: + default: none + enable: + - errcheck + - govet + - ineffassign + - staticcheck + +formatters: + enable: + - gofmt + +run: + tests: true + timeout: 3m diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..c176d0f --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,11 @@ +version: "2" + +linters: + default: none + enable: + - logslinter + settings: + custom: + logslinter: + type: module + description: Validate literal slog and zap log messages with logsLinter. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5c290da --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on Keep a Changelog and the project uses Semantic Versioning. + +## [Unreleased] + +### Added + +- + +### Changed + +- + +### Fixed + +- + +### Security + +- diff --git a/README.md b/README.md index 5b0e331..10b1e20 100644 --- a/README.md +++ b/README.md @@ -1 +1,219 @@ # logsLinter + +[![CI](https://github.com/rTexty/logsLinter/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/rTexty/logsLinter/actions/workflows/ci.yml) +[![CodeQL](https://github.com/rTexty/logsLinter/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/rTexty/logsLinter/actions/workflows/codeql.yml) +[![Release](https://github.com/rTexty/logsLinter/actions/workflows/release.yml/badge.svg)](https://github.com/rTexty/logsLinter/actions/workflows/release.yml) +[![Latest Release](https://img.shields.io/github/v/release/rTexty/logsLinter)](https://github.com/rTexty/logsLinter/releases) + +Production-ready Go analyzer for validating log messages in `log/slog` and `go.uber.org/zap` codebases. + +The analyzer is implemented as `golang.org/x/tools/go/analysis` and can run both as a standalone binary and through the `golangci-lint` module plugin workflow. + +## Status Panel + +| Signal | Status | +| --- | --- | +| CI | formatting, vet, tests, build, repository lint | +| Security | CodeQL, dependency review, Dependabot, SECURITY policy | +| Releases | tag-based GitHub Release workflow with packaged binaries and checksums | +| Tooling | standalone CLI, golangci-lint module plugin examples | + +## Supported APIs + +- `log/slog` top-level calls: `Debug`, `Info`, `Warn`, `Error` +- `log/slog` context variants: `DebugContext`, `InfoContext`, `WarnContext`, `ErrorContext` +- `log/slog` structured variants: `Log`, `LogAttrs` +- `*slog.Logger` methods, including chained `With(...)` and `WithGroup(...)` +- `*zap.Logger` methods: `Debug`, `Info`, `Warn`, `Error` +- `*zap.SugaredLogger` methods with explicit message argument: `Debugw`, `Infow`, `Warnw`, `Errorw` + +## Goals + +- Enforce consistent log message style +- Catch non-English or non-ASCII log text +- Flag decorative punctuation and emoji in log messages +- Prevent accidental logging of potentially sensitive data +- Integrate with standard Go analysis tooling and `golangci-lint` + +## Rules + +- Log messages must start with a lowercase letter +- Log messages must contain English ASCII text only +- Log messages must not contain decorative special characters or emoji +- Log messages must not contain sensitive keywords such as `password`, `token`, or `secret` + +## Skipped Cases + +- Non-literal messages such as variables, function calls, and `fmt.Sprintf(...)` +- Literal plus variable concatenation such as `"password: " + secret` +- `zap.SugaredLogger` print-style methods such as `Info(...)`, `Warn(...)`, `Error(...)` +- `zap.SugaredLogger` format-style methods such as `Infof(...)`, `Warnf(...)`, `Errorf(...)` + +## Development + +### Requirements + +- Go 1.23+ + +### Commands + +```bash +# Build +go build ./... + +# Build standalone analyzer binary +go build -o ./bin/logslinter ./cmd/logslinter + +# Test +go test ./... -race -count=1 + +# Format +gofmt -w . + +# Tidy dependencies +go mod tidy +``` + +## Standalone Usage + +Build the analyzer: + +```bash +go build -o ./bin/logslinter ./cmd/logslinter +``` + +Run it directly on packages: + +```bash +./bin/logslinter ./... +``` + +Or use it through `go vet` as a `vettool`: + +```bash +go vet -vettool=$(pwd)/bin/logslinter ./... +``` + +The command exits with a non-zero status when diagnostics are reported or package loading fails. + +Example diagnostics: + +```text +internal/service/auth.go:42:18: log message must start with a lowercase letter +internal/service/auth.go:42:18: log message may contain sensitive data +``` + +Example: + +```go +slog.Info("Starting auth token rotation") +``` + +This call reports: + +- `log message must start with a lowercase letter` +- `log message may contain sensitive data` + +## golangci-lint Module Plugin + +The repository contains example plugin configuration for the current module plugin workflow: + +- [.custom-gcl.yml](/Users/rtexty/Documents/MyProjects/Programming/logsLinter/.custom-gcl.yml) builds a custom `golangci-lint` binary with `logsLinter` linked in +- [.golangci.yml](/Users/rtexty/Documents/MyProjects/Programming/logsLinter/.golangci.yml) enables the custom linter as a module plugin + +Build a custom `golangci-lint` binary: + +```bash +golangci-lint custom +``` + +With the example config in this repository, the custom binary is written to `./bin/custom-golangci-lint`. + +Run the custom binary: + +```bash +./bin/custom-golangci-lint run ./... +``` + +Minimal local-path plugin config: + +```yaml +version: v2.11.2 +name: custom-golangci-lint +destination: ./bin + +plugins: + - module: github.com/rTexty/logsLinter + import: github.com/rTexty/logsLinter/plugin + path: . +``` + +```yaml +version: "2" + +linters: + default: none + enable: + - logslinter + settings: + custom: + logslinter: + type: module + description: Validate literal slog and zap log messages with logsLinter. +``` + +## Verification + +- Unit coverage exists for rule evaluation, extraction, diagnostics, and logger call inspection +- Integration coverage runs through `analysistest` fixtures for `slog`, `zap`, and mixed edge cases +- Current verification baseline is `go test ./...` + +## Known Limitations + +- Only string literals and literal-only concatenations are analyzed +- Dynamic messages such as variables, `fmt.Sprintf(...)`, and mixed literal-plus-variable expressions are intentionally skipped +- `zap.SugaredLogger` print-style and format-style methods stay out of scope in the MVP +- The lowercase-start rule does not expose a `SuggestedFix`; auto-rewriting the first rune safely is deferred to avoid Unicode and intent edge cases +- Internal config types exist for future rule toggles and additional sensitive keywords, but no public runtime configuration is enabled yet + +## Release Checklist + +- `go test ./... -race -count=1` +- `go build ./...` +- `go build -o ./bin/logslinter ./cmd/logslinter` +- `golangci-lint custom -v` +- `./bin/custom-golangci-lint run ./...` +- Verify standalone output on a sample package or repository +- Create and push the release tag documented below + +## Repository Automation + +- GitHub Actions CI runs formatting checks, `go vet`, tests, build, and repository linting. +- CodeQL runs on pull requests and on a weekly schedule. +- Dependency review runs on pull requests. +- Dependabot tracks both Go modules and GitHub Actions. +- Releases are built from Git tags matching `v*` and publish packaged binaries plus `SHA256SUMS.txt`. +- Changelog and release note conventions are documented in `CHANGELOG.md` and `docs/release-policy.md`. +- Recommended GitHub branch ruleset settings are documented in `docs/github-ruleset.md`. + +## Release Process + +Create and push a semantic version tag: + +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` + +The release workflow will: + +- run tests before packaging +- build `logslinter` for Linux, macOS, and Windows +- upload `.tar.gz` and `.zip` artifacts +- publish a GitHub Release with generated notes and `SHA256SUMS.txt` + +Release note categorization is driven by pull request labels configured in `.github/release.yml`. + +## Status + +The analyzer MVP is functional: supported `slog` and `zap` calls are inspected, literal messages are validated against the four core rules, and diagnostics are covered by unit tests plus `analysistest` fixtures. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4937c7c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Supported Versions + +The repository is under active development. Security fixes are applied to the latest branch state first. + +## Reporting a Vulnerability + +Do not open a public GitHub issue for undisclosed vulnerabilities. + +Report security issues through private GitHub security reporting or contact the repository owner directly with: + +- affected version or commit +- impact summary +- minimal reproduction or proof of concept +- suggested remediation, if available + +You should receive an acknowledgement within a reasonable time after report delivery. diff --git a/cmd/logslinter/main.go b/cmd/logslinter/main.go new file mode 100644 index 0000000..2ae049b --- /dev/null +++ b/cmd/logslinter/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/rTexty/logsLinter/internal/analyzer" + "golang.org/x/tools/go/analysis/singlechecker" +) + +func main() { + singlechecker.Main(analyzer.Analyzer) +} diff --git a/docs/github-ruleset.md b/docs/github-ruleset.md new file mode 100644 index 0000000..7da01a4 --- /dev/null +++ b/docs/github-ruleset.md @@ -0,0 +1,35 @@ +# GitHub Ruleset Recommendations + +This repository contains the automation and policy files that can live in Git. +GitHub repository rulesets themselves are configured in the GitHub UI and are not reliably portable as repository files. + +## Recommended Branch Ruleset + +Target branch pattern: + +- `main` + +Recommended protections: + +- require pull requests before merge +- require at least 1 approval +- dismiss stale approvals on new commits +- require conversation resolution before merge +- require status checks before merge +- block force pushes +- block branch deletion +- require linear history + +## Recommended Required Status Checks + +- `build-test` +- `dependency-review` +- `analyze` +- `actionlint` + +## Recommended Repository Settings + +- enable vulnerability alerts +- enable Dependabot security updates +- enable automatic deletion of merged branches +- enable private vulnerability reporting diff --git a/docs/release-policy.md b/docs/release-policy.md new file mode 100644 index 0000000..236c824 --- /dev/null +++ b/docs/release-policy.md @@ -0,0 +1,32 @@ +# Release Policy + +## Versioning + +The repository uses Semantic Versioning tags: + +- `MAJOR` for breaking changes +- `MINOR` for backward-compatible features +- `PATCH` for backward-compatible fixes + +Release tags must use the format `vX.Y.Z`. + +## Changelog Policy + +- Keep pending changes under `Unreleased` in `CHANGELOG.md`. +- Move relevant entries from `Unreleased` into the released version section when creating a tag. +- Prefer concise user-facing entries over internal implementation detail. +- Security-sensitive details should only be disclosed when safe to publish. + +## Release Notes Policy + +- GitHub Release notes are generated automatically from merged pull requests and labels. +- Use labels such as `feature`, `enhancement`, `bug`, `security`, `documentation`, `chore`, and `breaking-change` to place changes in the correct section. +- Use `skip-release-notes` for internal changes that should not appear in public notes. + +## Recommended Release Checklist + +1. Ensure CI is green on the release commit. +2. Update `CHANGELOG.md`. +3. Confirm release labels are accurate on merged pull requests. +4. Create and push a `vX.Y.Z` tag. +5. Verify artifacts and `SHA256SUMS.txt` in the published GitHub Release. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f80ea53 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/rTexty/logsLinter + +go 1.24.0 + +require ( + github.com/golangci/plugin-module-register v0.1.2 + golang.org/x/tools v0.42.0 +) + +require ( + golang.org/x/mod v0.33.0 // indirect + golang.org/x/sync v0.19.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3bbf9d7 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg= +github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go new file mode 100644 index 0000000..acf0d68 --- /dev/null +++ b/internal/analyzer/analyzer.go @@ -0,0 +1,49 @@ +package analyzer + +import ( + "go/ast" + + "golang.org/x/tools/go/analysis" +) + +// Analyzer reports log message policy violations in supported logger calls. +var Analyzer = &analysis.Analyzer{ + Name: "logslinter", + Doc: "report invalid log messages in supported logger calls", + Run: run, +} + +func run(pass *analysis.Pass) (any, error) { + for _, file := range pass.Files { + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + + analyzeCall(pass, call) + return true + }) + } + + return nil, nil +} + +func analyzeCall(pass *analysis.Pass, call *ast.CallExpr) { + inspectedCall, ok := inspectLogCall(pass, call) + if !ok { + return + } + + sample, ok := extractMessage(inspectedCall.message) + if !ok { + return + } + + violations := evaluateRules(sample) + if len(violations) == 0 { + return + } + + reportDiagnostics(pass, buildDiagnostics(inspectedCall.message, violations)) +} diff --git a/internal/analyzer/analyzer_test.go b/internal/analyzer/analyzer_test.go new file mode 100644 index 0000000..af8ecc6 --- /dev/null +++ b/internal/analyzer/analyzer_test.go @@ -0,0 +1,23 @@ +package analyzer + +import ( + "path/filepath" + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +func TestAnalyzer(t *testing.T) { + testdata, err := filepath.Abs(filepath.Join("..", "..", "testdata")) + if err != nil { + t.Fatalf("filepath.Abs(testdata): %v", err) + } + + for _, packageName := range []string{"slogcases", "zapcases", "mixedcases"} { + packageName := packageName + + t.Run(packageName, func(t *testing.T) { + analysistest.Run(t, testdata, Analyzer, packageName) + }) + } +} diff --git a/internal/analyzer/config.go b/internal/analyzer/config.go new file mode 100644 index 0000000..43f12e5 --- /dev/null +++ b/internal/analyzer/config.go @@ -0,0 +1,37 @@ +package analyzer + +type Config struct { + Rules RuleConfig + SensitiveData SensitiveDataConfig +} + +type RuleConfig struct { + LowercaseStart bool + ASCIIOnly bool + NoSpecialChars bool + SensitiveData bool +} + +type SensitiveDataConfig struct { + AdditionalKeywords []string +} + +var defaultConfig = Config{ + Rules: RuleConfig{ + LowercaseStart: true, + ASCIIOnly: true, + NoSpecialChars: true, + SensitiveData: true, + }, +} + +func (config Config) normalized() Config { + normalized := config + + normalized.SensitiveData.AdditionalKeywords = append( + []string{}, + normalized.SensitiveData.AdditionalKeywords..., + ) + + return normalized +} diff --git a/internal/analyzer/diagnostics.go b/internal/analyzer/diagnostics.go new file mode 100644 index 0000000..a58132c --- /dev/null +++ b/internal/analyzer/diagnostics.go @@ -0,0 +1,48 @@ +package analyzer + +import ( + "go/ast" + + "golang.org/x/tools/go/analysis" +) + +type diagnosticKey struct { + ruleID string + pos int +} + +func buildDiagnostics(expr ast.Expr, violations []violation) []analysis.Diagnostic { + if expr == nil || len(violations) == 0 { + return nil + } + + diagnostics := make([]analysis.Diagnostic, 0, len(violations)) + seen := make(map[diagnosticKey]struct{}, len(violations)) + + for _, currentViolation := range violations { + key := diagnosticKey{ + ruleID: currentViolation.ruleID, + pos: int(expr.Pos()), + } + + if _, ok := seen[key]; ok { + continue + } + + seen[key] = struct{}{} + diagnostics = append(diagnostics, analysis.Diagnostic{ + Pos: expr.Pos(), + End: expr.End(), + Category: currentViolation.ruleID, + Message: currentViolation.message, + }) + } + + return diagnostics +} + +func reportDiagnostics(pass *analysis.Pass, diagnostics []analysis.Diagnostic) { + for _, diagnostic := range diagnostics { + pass.Report(diagnostic) + } +} diff --git a/internal/analyzer/diagnostics_test.go b/internal/analyzer/diagnostics_test.go new file mode 100644 index 0000000..0c7b49b --- /dev/null +++ b/internal/analyzer/diagnostics_test.go @@ -0,0 +1,59 @@ +package analyzer + +import ( + "go/parser" + "testing" +) + +func TestBuildDiagnostics(t *testing.T) { + t.Parallel() + + expr, err := parser.ParseExpr(`"Starting server"`) + if err != nil { + t.Fatalf("ParseExpr: %v", err) + } + + violations := []violation{ + {ruleID: ruleLowercaseStart, message: msgLowercaseStart}, + {ruleID: ruleLowercaseStart, message: msgLowercaseStart}, + {ruleID: ruleASCIIOnly, message: msgASCIIOnly}, + } + + diagnostics := buildDiagnostics(expr, violations) + if len(diagnostics) != 2 { + t.Fatalf("buildDiagnostics() count = %d, want 2", len(diagnostics)) + } + + if diagnostics[0].Category != ruleLowercaseStart { + t.Fatalf("first diagnostic category = %q, want %q", diagnostics[0].Category, ruleLowercaseStart) + } + + if diagnostics[0].Message != msgLowercaseStart { + t.Fatalf("first diagnostic message = %q, want %q", diagnostics[0].Message, msgLowercaseStart) + } + + if diagnostics[0].Pos != expr.Pos() || diagnostics[0].End != expr.End() { + t.Fatalf("first diagnostic range = [%d,%d], want [%d,%d]", diagnostics[0].Pos, diagnostics[0].End, expr.Pos(), expr.End()) + } + + if diagnostics[1].Category != ruleASCIIOnly { + t.Fatalf("second diagnostic category = %q, want %q", diagnostics[1].Category, ruleASCIIOnly) + } +} + +func TestBuildDiagnosticsNilCases(t *testing.T) { + t.Parallel() + + if diagnostics := buildDiagnostics(nil, []violation{{ruleID: ruleASCIIOnly, message: msgASCIIOnly}}); diagnostics != nil { + t.Fatalf("buildDiagnostics(nil, violations) = %#v, want nil", diagnostics) + } + + expr, err := parser.ParseExpr(`"ok"`) + if err != nil { + t.Fatalf("ParseExpr: %v", err) + } + + if diagnostics := buildDiagnostics(expr, nil); diagnostics != nil { + t.Fatalf("buildDiagnostics(expr, nil) = %#v, want nil", diagnostics) + } +} diff --git a/internal/analyzer/extract.go b/internal/analyzer/extract.go new file mode 100644 index 0000000..61126a9 --- /dev/null +++ b/internal/analyzer/extract.go @@ -0,0 +1,72 @@ +package analyzer + +import ( + "go/ast" + "go/token" + "strconv" + "strings" +) + +func extractMessage(expr ast.Expr) (messageSample, bool) { + parts, ok := extractMessageParts(expr) + if !ok { + return messageSample{}, false + } + + return newMessageSample(parts), true +} + +func extractMessageParts(expr ast.Expr) ([]string, bool) { + switch currentExpr := expr.(type) { + case *ast.BasicLit: + sample, ok := extractBasicLiteral(currentExpr) + if !ok { + return nil, false + } + + return sample.parts, true + case *ast.BinaryExpr: + if currentExpr.Op != token.ADD { + return nil, false + } + + left, ok := extractMessageParts(currentExpr.X) + if !ok { + return nil, false + } + + right, ok := extractMessageParts(currentExpr.Y) + if !ok { + return nil, false + } + + return append(append([]string{}, left...), right...), true + default: + return nil, false + } +} + +func extractBasicLiteral(literal *ast.BasicLit) (messageSample, bool) { + if literal.Kind != token.STRING { + return messageSample{}, false + } + + text, err := strconv.Unquote(literal.Value) + if err != nil { + return messageSample{}, false + } + + return messageSample{ + text: text, + parts: []string{text}, + }, true +} + +func newMessageSample(parts []string) messageSample { + normalizedParts := append([]string{}, parts...) + + return messageSample{ + text: strings.Join(normalizedParts, ""), + parts: normalizedParts, + } +} diff --git a/internal/analyzer/extract_test.go b/internal/analyzer/extract_test.go new file mode 100644 index 0000000..862deef --- /dev/null +++ b/internal/analyzer/extract_test.go @@ -0,0 +1,99 @@ +package analyzer + +import ( + "go/ast" + "go/parser" + "reflect" + "testing" +) + +func TestExtractMessage(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + expr string + wantOK bool + wantText string + wantParts []string + }{ + { + name: "string literal", + expr: `"starting server"`, + wantOK: true, + wantText: "starting server", + wantParts: []string{"starting server"}, + }, + { + name: "raw string literal", + expr: "`starting server`", + wantOK: true, + wantText: "starting server", + wantParts: []string{"starting server"}, + }, + { + name: "nested concatenation", + expr: `"start" + "ing" + " server"`, + wantOK: true, + wantText: "starting server", + wantParts: []string{"start", "ing", " server"}, + }, + { + name: "variable is skipped", + expr: `message`, + wantOK: false, + }, + { + name: "function call is skipped", + expr: `buildMessage()`, + wantOK: false, + }, + { + name: "formatted expression is skipped", + expr: `fmt.Sprintf("hello %s", name)`, + wantOK: false, + }, + { + name: "literal plus variable is skipped", + expr: `"password: " + password`, + wantOK: false, + }, + } + + for _, testCase := range testCases { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + expr := mustParseExpr(t, testCase.expr) + got, ok := extractMessage(expr) + if ok != testCase.wantOK { + t.Fatalf("extractMessage(%q) ok = %v, want %v", testCase.expr, ok, testCase.wantOK) + } + + if !testCase.wantOK { + return + } + + if got.text != testCase.wantText { + t.Fatalf("extractMessage(%q) text = %q, want %q", testCase.expr, got.text, testCase.wantText) + } + + if !reflect.DeepEqual(got.parts, testCase.wantParts) { + t.Fatalf("extractMessage(%q) parts = %#v, want %#v", testCase.expr, got.parts, testCase.wantParts) + } + }) + } +} + +func mustParseExpr(t *testing.T, expr string) ast.Expr { + t.Helper() + + parsedExpr, err := parser.ParseExpr(expr) + if err != nil { + t.Fatalf("ParseExpr(%q): %v", expr, err) + } + + return parsedExpr +} diff --git a/internal/analyzer/inspect.go b/internal/analyzer/inspect.go new file mode 100644 index 0000000..ea3d5da --- /dev/null +++ b/internal/analyzer/inspect.go @@ -0,0 +1,203 @@ +package analyzer + +import ( + "go/ast" + "go/types" + + "golang.org/x/tools/go/analysis" +) + +const slogPackagePath = "log/slog" +const zapPackagePath = "go.uber.org/zap" + +type loggerFamily string + +const ( + loggerFamilyUnknown loggerFamily = "" + loggerFamilySlog loggerFamily = "slog" + loggerFamilyZap loggerFamily = "zap" +) + +type logCall struct { + family loggerFamily + message ast.Expr +} + +func inspectLogCall(pass *analysis.Pass, call *ast.CallExpr) (logCall, bool) { + if inspectedCall, ok := inspectSlogCall(pass, call); ok { + return inspectedCall, true + } + + if inspectedCall, ok := inspectZapCall(pass, call); ok { + return inspectedCall, true + } + + return logCall{}, false +} + +func inspectSlogCall(pass *analysis.Pass, call *ast.CallExpr) (logCall, bool) { + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return logCall{}, false + } + + messageIndex, ok := slogMessageArgIndex(pass, selector) + if !ok || messageIndex >= len(call.Args) { + return logCall{}, false + } + + return logCall{ + family: loggerFamilySlog, + message: call.Args[messageIndex], + }, true +} + +func inspectZapCall(pass *analysis.Pass, call *ast.CallExpr) (logCall, bool) { + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return logCall{}, false + } + + messageIndex, ok := zapMessageArgIndex(pass, selector) + if !ok || messageIndex >= len(call.Args) { + return logCall{}, false + } + + return logCall{ + family: loggerFamilyZap, + message: call.Args[messageIndex], + }, true +} + +func slogMessageArgIndex(pass *analysis.Pass, selector *ast.SelectorExpr) (int, bool) { + if isSlogPackageSelector(pass, selector) { + return slogFunctionMessageArgIndex(selector.Sel.Name) + } + + if isSlogLoggerMethod(pass, selector) { + return slogMethodMessageArgIndex(selector.Sel.Name) + } + + return 0, false +} + +func slogFunctionMessageArgIndex(name string) (int, bool) { + switch name { + case "Debug", "Info", "Warn", "Error": + return 0, true + case "DebugContext", "InfoContext", "WarnContext", "ErrorContext": + return 1, true + case "Log", "LogAttrs": + return 2, true + default: + return 0, false + } +} + +func slogMethodMessageArgIndex(name string) (int, bool) { + switch name { + case "Debug", "Info", "Warn", "Error": + return 0, true + case "DebugContext", "InfoContext", "WarnContext", "ErrorContext": + return 1, true + case "Log", "LogAttrs": + return 2, true + default: + return 0, false + } +} + +func zapMessageArgIndex(pass *analysis.Pass, selector *ast.SelectorExpr) (int, bool) { + if isZapLoggerMethod(pass, selector) { + return zapLoggerMessageArgIndex(selector.Sel.Name) + } + + if isZapSugaredLoggerMethod(pass, selector) { + return zapSugaredLoggerMessageArgIndex(selector.Sel.Name) + } + + return 0, false +} + +func zapLoggerMessageArgIndex(name string) (int, bool) { + switch name { + case "Debug", "Info", "Warn", "Error": + return 0, true + default: + return 0, false + } +} + +func zapSugaredLoggerMessageArgIndex(name string) (int, bool) { + switch name { + case "Debugw", "Infow", "Warnw", "Errorw": + return 0, true + default: + return 0, false + } +} + +func isSlogPackageSelector(pass *analysis.Pass, selector *ast.SelectorExpr) bool { + pkgIdent, ok := selector.X.(*ast.Ident) + if !ok { + return false + } + + pkgName, ok := pass.TypesInfo.Uses[pkgIdent].(*types.PkgName) + if !ok { + return false + } + + imported := pkgName.Imported() + return imported != nil && imported.Path() == slogPackagePath +} + +func isSlogLoggerMethod(pass *analysis.Pass, selector *ast.SelectorExpr) bool { + selection := pass.TypesInfo.Selections[selector] + if selection == nil { + return false + } + + return isNamedType(selection.Recv(), slogPackagePath, "Logger") +} + +func isZapLoggerMethod(pass *analysis.Pass, selector *ast.SelectorExpr) bool { + selection := pass.TypesInfo.Selections[selector] + if selection == nil { + return false + } + + return isNamedType(selection.Recv(), zapPackagePath, "Logger") +} + +func isZapSugaredLoggerMethod(pass *analysis.Pass, selector *ast.SelectorExpr) bool { + selection := pass.TypesInfo.Selections[selector] + if selection == nil { + return false + } + + return isNamedType(selection.Recv(), zapPackagePath, "SugaredLogger") +} + +func isNamedType(currentType types.Type, packagePath, typeName string) bool { + for { + pointer, ok := currentType.(*types.Pointer) + if !ok { + break + } + + currentType = pointer.Elem() + } + + named, ok := currentType.(*types.Named) + if !ok { + return false + } + + object := named.Obj() + if object == nil || object.Name() != typeName || object.Pkg() == nil { + return false + } + + return object.Pkg().Path() == packagePath +} diff --git a/internal/analyzer/inspect_test.go b/internal/analyzer/inspect_test.go new file mode 100644 index 0000000..deb37e9 --- /dev/null +++ b/internal/analyzer/inspect_test.go @@ -0,0 +1,320 @@ +package analyzer + +import ( + "bytes" + "go/ast" + "go/format" + "go/importer" + "go/parser" + "go/token" + "go/types" + "testing" + + "golang.org/x/tools/go/analysis" +) + +func TestInspectLogCallRecognizesSlogMessages(t *testing.T) { + t.Parallel() + + pass, calls := mustTypeCheckCalls(t, `package fixture + +import ( + "context" + "fmt" + "io" + "log/slog" +) + +var ( + ctx = context.Background() + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) +) + +func example() { + slog.Info("top level") + slog.InfoContext(ctx, "context call") + slog.Log(ctx, slog.LevelInfo, "log call") + slog.LogAttrs(ctx, slog.LevelInfo, "attrs call") + logger.Info("method call") + logger.WarnContext(ctx, "warn context") + logger.Log(ctx, slog.LevelWarn, "method log") + logger.LogAttrs(ctx, slog.LevelError, "method attrs") + logger.With("component", "api").Info("with chain") + logger.WithGroup("db").ErrorContext(ctx, "group chain") + fmt.Println("skip") + slog.SetDefault(logger) +} +`) + + testCases := []struct { + callText string + wantMsg string + wantOK bool + }{ + {callText: `slog.Info("top level")`, wantMsg: `"top level"`, wantOK: true}, + {callText: `slog.InfoContext(ctx, "context call")`, wantMsg: `"context call"`, wantOK: true}, + {callText: `slog.Log(ctx, slog.LevelInfo, "log call")`, wantMsg: `"log call"`, wantOK: true}, + {callText: `slog.LogAttrs(ctx, slog.LevelInfo, "attrs call")`, wantMsg: `"attrs call"`, wantOK: true}, + {callText: `logger.Info("method call")`, wantMsg: `"method call"`, wantOK: true}, + {callText: `logger.WarnContext(ctx, "warn context")`, wantMsg: `"warn context"`, wantOK: true}, + {callText: `logger.Log(ctx, slog.LevelWarn, "method log")`, wantMsg: `"method log"`, wantOK: true}, + {callText: `logger.LogAttrs(ctx, slog.LevelError, "method attrs")`, wantMsg: `"method attrs"`, wantOK: true}, + {callText: `logger.With("component", "api").Info("with chain")`, wantMsg: `"with chain"`, wantOK: true}, + {callText: `logger.WithGroup("db").ErrorContext(ctx, "group chain")`, wantMsg: `"group chain"`, wantOK: true}, + {callText: `fmt.Println("skip")`, wantOK: false}, + {callText: `slog.SetDefault(logger)`, wantOK: false}, + } + + for _, testCase := range testCases { + testCase := testCase + + t.Run(testCase.callText, func(t *testing.T) { + t.Parallel() + + call := calls[testCase.callText] + if call == nil { + t.Fatalf("call %q not found", testCase.callText) + } + + inspectedCall, ok := inspectLogCall(pass, call) + if ok != testCase.wantOK { + t.Fatalf("inspectLogCall(%s) ok = %v, want %v", testCase.callText, ok, testCase.wantOK) + } + + if !testCase.wantOK { + return + } + + if inspectedCall.family != loggerFamilySlog { + t.Fatalf("inspectLogCall(%s) family = %q, want %q", testCase.callText, inspectedCall.family, loggerFamilySlog) + } + + if got := formatExpr(t, pass.Fset, inspectedCall.message); got != testCase.wantMsg { + t.Fatalf("inspectLogCall(%s) message = %s, want %s", testCase.callText, got, testCase.wantMsg) + } + }) + } +} + +func TestInspectLogCallRecognizesZapMessages(t *testing.T) { + t.Parallel() + + pass, calls := mustTypeCheckCallsWithImporter(t, `package fixture + +import "go.uber.org/zap" + +var ( + logger = zap.NewNop() + sugar = logger.Sugar() +) + +func example() { + logger.Info("logger info") + logger.Warn("logger warn", zap.String("component", "api")) + logger.With(zap.String("component", "db")).Error("logger with") + logger.Sugar().Infow("sugar chain", "component", "api") + sugar.Debugw("sugar debug", "component", "worker") + sugar.Errorw("sugar error", "component", "db") + sugar.Info("skip print style") + sugar.Infof("skip %s", "format") +} +`, newFixtureImporter(t)) + + testCases := []struct { + callText string + wantMsg string + wantOK bool + }{ + {callText: `logger.Info("logger info")`, wantMsg: `"logger info"`, wantOK: true}, + {callText: `logger.Warn("logger warn", zap.String("component", "api"))`, wantMsg: `"logger warn"`, wantOK: true}, + {callText: `logger.With(zap.String("component", "db")).Error("logger with")`, wantMsg: `"logger with"`, wantOK: true}, + {callText: `logger.Sugar().Infow("sugar chain", "component", "api")`, wantMsg: `"sugar chain"`, wantOK: true}, + {callText: `sugar.Debugw("sugar debug", "component", "worker")`, wantMsg: `"sugar debug"`, wantOK: true}, + {callText: `sugar.Errorw("sugar error", "component", "db")`, wantMsg: `"sugar error"`, wantOK: true}, + {callText: `sugar.Info("skip print style")`, wantOK: false}, + {callText: `sugar.Infof("skip %s", "format")`, wantOK: false}, + } + + for _, testCase := range testCases { + testCase := testCase + + t.Run(testCase.callText, func(t *testing.T) { + t.Parallel() + + call := calls[testCase.callText] + if call == nil { + t.Fatalf("call %q not found", testCase.callText) + } + + inspectedCall, ok := inspectLogCall(pass, call) + if ok != testCase.wantOK { + t.Fatalf("inspectLogCall(%s) ok = %v, want %v", testCase.callText, ok, testCase.wantOK) + } + + if !testCase.wantOK { + return + } + + if inspectedCall.family != loggerFamilyZap { + t.Fatalf("inspectLogCall(%s) family = %q, want %q", testCase.callText, inspectedCall.family, loggerFamilyZap) + } + + if got := formatExpr(t, pass.Fset, inspectedCall.message); got != testCase.wantMsg { + t.Fatalf("inspectLogCall(%s) message = %s, want %s", testCase.callText, got, testCase.wantMsg) + } + }) + } +} + +func mustTypeCheckCalls(t *testing.T, source string) (*analysis.Pass, map[string]*ast.CallExpr) { + t.Helper() + + return mustTypeCheckCallsWithImporter(t, source, importer.Default()) + +} + +func mustTypeCheckCallsWithImporter(t *testing.T, source string, currentImporter types.Importer) (*analysis.Pass, map[string]*ast.CallExpr) { + t.Helper() + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "fixture.go", source, 0) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + + info := &types.Info{ + Defs: make(map[*ast.Ident]types.Object), + Uses: make(map[*ast.Ident]types.Object), + Selections: make(map[*ast.SelectorExpr]*types.Selection), + Types: make(map[ast.Expr]types.TypeAndValue), + } + + config := types.Config{Importer: currentImporter} + pkg, err := config.Check("fixture", fset, []*ast.File{file}, info) + if err != nil { + t.Fatalf("Check: %v", err) + } + + pass := &analysis.Pass{ + Fset: fset, + Files: []*ast.File{file}, + Pkg: pkg, + TypesInfo: info, + } + + calls := make(map[string]*ast.CallExpr) + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + + calls[formatExpr(t, fset, call)] = call + return true + }) + + return pass, calls +} + +func newFixtureImporter(t *testing.T) types.Importer { + t.Helper() + + defaultImporter := importer.Default() + packages := map[string]*types.Package{ + zapPackagePath: mustTypeCheckPackage(t, zapPackagePath, `package zap + +type Field struct{} + +type Logger struct{} + +type SugaredLogger struct{} + +func NewNop() *Logger { return &Logger{} } + +func String(string, string) Field { return Field{} } + +func (*Logger) Debug(string, ...Field) {} + +func (*Logger) Info(string, ...Field) {} + +func (*Logger) Warn(string, ...Field) {} + +func (*Logger) Error(string, ...Field) {} + +func (logger *Logger) With(...Field) *Logger { return logger } + +func (*Logger) Sugar() *SugaredLogger { return &SugaredLogger{} } + +func (*SugaredLogger) Debugw(string, ...any) {} + +func (*SugaredLogger) Infow(string, ...any) {} + +func (*SugaredLogger) Warnw(string, ...any) {} + +func (*SugaredLogger) Errorw(string, ...any) {} + +func (*SugaredLogger) Debug(...any) {} + +func (*SugaredLogger) Info(...any) {} + +func (*SugaredLogger) Warn(...any) {} + +func (*SugaredLogger) Error(...any) {} + +func (*SugaredLogger) Debugf(string, ...any) {} + +func (*SugaredLogger) Infof(string, ...any) {} + +func (*SugaredLogger) Warnf(string, ...any) {} + +func (*SugaredLogger) Errorf(string, ...any) {} +`), + } + + return fixtureImporter{ + fallback: defaultImporter, + packages: packages, + } +} + +func mustTypeCheckPackage(t *testing.T, path, source string) *types.Package { + t.Helper() + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path+".go", source, 0) + if err != nil { + t.Fatalf("ParseFile(%s): %v", path, err) + } + + config := types.Config{} + pkg, err := config.Check(path, fset, []*ast.File{file}, nil) + if err != nil { + t.Fatalf("Check(%s): %v", path, err) + } + + return pkg +} + +type fixtureImporter struct { + fallback types.Importer + packages map[string]*types.Package +} + +func (importer fixtureImporter) Import(path string) (*types.Package, error) { + if pkg, ok := importer.packages[path]; ok { + return pkg, nil + } + + return importer.fallback.Import(path) +} + +func formatExpr(t *testing.T, fset *token.FileSet, expr ast.Node) string { + t.Helper() + + var buffer bytes.Buffer + if err := format.Node(&buffer, fset, expr); err != nil { + t.Fatalf("format.Node: %v", err) + } + + return buffer.String() +} diff --git a/internal/analyzer/rules.go b/internal/analyzer/rules.go new file mode 100644 index 0000000..e2d7ae2 --- /dev/null +++ b/internal/analyzer/rules.go @@ -0,0 +1,166 @@ +package analyzer + +import ( + "regexp" + "strings" + "unicode" + "unicode/utf8" +) + +const ( + ruleLowercaseStart = "lowercase-start" + ruleASCIIOnly = "english-ascii-only" + ruleNoSpecialChars = "no-special-chars-or-emoji" + ruleSensitiveData = "no-sensitive-data" +) + +const ( + msgLowercaseStart = "log message must start with a lowercase letter" + msgASCIIOnly = "log message must be in English (ASCII only)" + msgNoSpecialChars = "log message must not contain special characters or emoji" + msgSensitiveData = "log message may contain sensitive data" +) + +var sensitiveDataPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(^|[^a-z0-9_])password([^a-z0-9_]|$)`), + regexp.MustCompile(`(^|[^a-z0-9_])passwd([^a-z0-9_]|$)`), + regexp.MustCompile(`(^|[^a-z0-9_])token([^a-z0-9_]|$)`), + regexp.MustCompile(`(^|[^a-z0-9_])secret([^a-z0-9_]|$)`), + regexp.MustCompile(`(^|[^a-z0-9_])api_key([^a-z0-9_]|$)`), + regexp.MustCompile(`(^|[^a-z0-9_])apikey([^a-z0-9_]|$)`), + regexp.MustCompile(`(^|[^a-z0-9_])auth([^a-z0-9_]|$)`), +} + +type messageSample struct { + text string + parts []string +} + +type violation struct { + ruleID string + message string +} + +type ruleDefinition struct { + ruleID string + message string + check func(messageSample) (violation, bool) +} + +var ruleDefinitions = []ruleDefinition{ + { + ruleID: ruleLowercaseStart, + message: msgLowercaseStart, + check: func(sample messageSample) (violation, bool) { + return checkLowercaseStart(sample.text) + }, + }, + { + ruleID: ruleASCIIOnly, + message: msgASCIIOnly, + check: func(sample messageSample) (violation, bool) { + return checkASCIIOnly(sample.text) + }, + }, + { + ruleID: ruleNoSpecialChars, + message: msgNoSpecialChars, + check: func(sample messageSample) (violation, bool) { + return checkNoSpecialCharsOrEmoji(sample.text) + }, + }, + { + ruleID: ruleSensitiveData, + message: msgSensitiveData, + check: checkSensitiveData, + }, +} + +func evaluateRules(sample messageSample) []violation { + violations := make([]violation, 0, 4) + + for _, definition := range ruleDefinitions { + violation, ok := definition.check(sample) + if ok { + violations = append(violations, violation) + } + } + + return violations +} + +func checkLowercaseStart(text string) (violation, bool) { + if text == "" { + return violation{}, false + } + + firstRune, _ := utf8.DecodeRuneInString(text) + if unicode.IsUpper(firstRune) { + return violation{ + ruleID: ruleLowercaseStart, + message: msgLowercaseStart, + }, true + } + + return violation{}, false +} + +func checkASCIIOnly(text string) (violation, bool) { + for _, currentRune := range text { + if currentRune < 0x20 || currentRune > 0x7e { + return violation{ + ruleID: ruleASCIIOnly, + message: msgASCIIOnly, + }, true + } + } + + return violation{}, false +} + +func checkNoSpecialCharsOrEmoji(text string) (violation, bool) { + if strings.Contains(text, "!") || strings.Contains(text, "...") || strings.HasSuffix(text, "?") { + return violation{ + ruleID: ruleNoSpecialChars, + message: msgNoSpecialChars, + }, true + } + + for _, currentRune := range text { + if isEmoji(currentRune) { + return violation{ + ruleID: ruleNoSpecialChars, + message: msgNoSpecialChars, + }, true + } + } + + return violation{}, false +} + +func checkSensitiveData(sample messageSample) (violation, bool) { + parts := sample.parts + if len(parts) == 0 { + parts = []string{sample.text} + } + + for _, part := range parts { + normalized := strings.ToLower(part) + + for _, pattern := range sensitiveDataPatterns { + if pattern.MatchString(normalized) { + return violation{ + ruleID: ruleSensitiveData, + message: msgSensitiveData, + }, true + } + } + } + + return violation{}, false +} + +func isEmoji(currentRune rune) bool { + return currentRune >= 0x1f300 && currentRune <= 0x1faff || + currentRune >= 0x2600 && currentRune <= 0x27bf +} diff --git a/internal/analyzer/rules_test.go b/internal/analyzer/rules_test.go new file mode 100644 index 0000000..d80e3f9 --- /dev/null +++ b/internal/analyzer/rules_test.go @@ -0,0 +1,275 @@ +package analyzer + +import "testing" + +func TestCheckLowercaseStart(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + text string + want bool + ruleID string + message string + }{ + { + name: "empty message is allowed", + text: "", + want: false, + }, + { + name: "lowercase start passes", + text: "starting server", + want: false, + }, + { + name: "uppercase start fails", + text: "Starting server", + want: true, + ruleID: ruleLowercaseStart, + message: msgLowercaseStart, + }, + { + name: "non-letter first rune is ignored", + text: "123 started", + want: false, + }, + } + + for _, testCase := range testCases { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + got, ok := checkLowercaseStart(testCase.text) + if ok != testCase.want { + t.Fatalf("checkLowercaseStart(%q) ok = %v, want %v", testCase.text, ok, testCase.want) + } + + if !testCase.want { + return + } + + if got.ruleID != testCase.ruleID { + t.Fatalf("checkLowercaseStart(%q) ruleID = %q, want %q", testCase.text, got.ruleID, testCase.ruleID) + } + + if got.message != testCase.message { + t.Fatalf("checkLowercaseStart(%q) message = %q, want %q", testCase.text, got.message, testCase.message) + } + }) + } +} + +func TestCheckASCIIOnly(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + text string + want bool + ruleID string + message string + }{ + { + name: "plain ascii passes", + text: "failed to connect", + want: false, + }, + { + name: "mixed ascii and cyrillic fails", + text: "failed ошибка", + want: true, + ruleID: ruleASCIIOnly, + message: msgASCIIOnly, + }, + { + name: "cyrillic fails", + text: "ошибка подключения", + want: true, + ruleID: ruleASCIIOnly, + message: msgASCIIOnly, + }, + { + name: "control characters fail", + text: "line\nbreak", + want: true, + ruleID: ruleASCIIOnly, + message: msgASCIIOnly, + }, + } + + for _, testCase := range testCases { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + got, ok := checkASCIIOnly(testCase.text) + if ok != testCase.want { + t.Fatalf("checkASCIIOnly(%q) ok = %v, want %v", testCase.text, ok, testCase.want) + } + + if !testCase.want { + return + } + + if got.ruleID != testCase.ruleID { + t.Fatalf("checkASCIIOnly(%q) ruleID = %q, want %q", testCase.text, got.ruleID, testCase.ruleID) + } + + if got.message != testCase.message { + t.Fatalf("checkASCIIOnly(%q) message = %q, want %q", testCase.text, got.message, testCase.message) + } + }) + } +} + +func TestCheckNoSpecialCharsOrEmoji(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + text string + want bool + ruleID string + message string + }{ + { + name: "plain message passes", + text: "connection established", + want: false, + }, + { + name: "exclamation mark fails", + text: "server started!", + want: true, + ruleID: ruleNoSpecialChars, + message: msgNoSpecialChars, + }, + { + name: "trailing question mark fails", + text: "connection lost?", + want: true, + ruleID: ruleNoSpecialChars, + message: msgNoSpecialChars, + }, + { + name: "ellipsis fails", + text: "waiting...", + want: true, + ruleID: ruleNoSpecialChars, + message: msgNoSpecialChars, + }, + { + name: "emoji fails", + text: "server started 🚀", + want: true, + ruleID: ruleNoSpecialChars, + message: msgNoSpecialChars, + }, + { + name: "hyphenated technical term passes", + text: "connecting to db-host", + want: false, + }, + } + + for _, testCase := range testCases { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + got, ok := checkNoSpecialCharsOrEmoji(testCase.text) + if ok != testCase.want { + t.Fatalf("checkNoSpecialCharsOrEmoji(%q) ok = %v, want %v", testCase.text, ok, testCase.want) + } + + if !testCase.want { + return + } + + if got.ruleID != testCase.ruleID { + t.Fatalf("checkNoSpecialCharsOrEmoji(%q) ruleID = %q, want %q", testCase.text, got.ruleID, testCase.ruleID) + } + + if got.message != testCase.message { + t.Fatalf("checkNoSpecialCharsOrEmoji(%q) message = %q, want %q", testCase.text, got.message, testCase.message) + } + }) + } +} + +func TestCheckSensitiveData(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + sample messageSample + want bool + ruleID string + message string + }{ + { + name: "safe authentication wording passes", + sample: messageSample{text: "user authenticated successfully"}, + want: false, + }, + { + name: "password keyword fails", + sample: messageSample{text: "user password invalid"}, + want: true, + ruleID: ruleSensitiveData, + message: msgSensitiveData, + }, + { + name: "substring false positive is ignored", + sample: messageSample{text: "oauth flow started"}, + want: false, + }, + { + name: "api key keyword fails", + sample: messageSample{text: "api_key provided"}, + want: true, + ruleID: ruleSensitiveData, + message: msgSensitiveData, + }, + { + name: "literal concatenation parts fail", + sample: messageSample{ + text: "user password: ", + parts: []string{"user password: ", ""}, + }, + want: true, + ruleID: ruleSensitiveData, + message: msgSensitiveData, + }, + } + + for _, testCase := range testCases { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + got, ok := checkSensitiveData(testCase.sample) + if ok != testCase.want { + t.Fatalf("checkSensitiveData(%+v) ok = %v, want %v", testCase.sample, ok, testCase.want) + } + + if !testCase.want { + return + } + + if got.ruleID != testCase.ruleID { + t.Fatalf("checkSensitiveData(%+v) ruleID = %q, want %q", testCase.sample, got.ruleID, testCase.ruleID) + } + + if got.message != testCase.message { + t.Fatalf("checkSensitiveData(%+v) message = %q, want %q", testCase.sample, got.message, testCase.message) + } + }) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..45565dd --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,27 @@ +package plugin + +import ( + "github.com/golangci/plugin-module-register/register" + projectanalyzer "github.com/rTexty/logsLinter/internal/analyzer" + "golang.org/x/tools/go/analysis" +) + +const pluginName = "logslinter" + +func init() { + register.Plugin(pluginName, New) +} + +type Linter struct{} + +func New(any) (register.LinterPlugin, error) { + return &Linter{}, nil +} + +func (l *Linter) BuildAnalyzers() ([]*analysis.Analyzer, error) { + return []*analysis.Analyzer{projectanalyzer.Analyzer}, nil +} + +func (l *Linter) GetLoadMode() string { + return register.LoadModeTypesInfo +} \ No newline at end of file diff --git a/testdata/src/go.uber.org/zap/zap.go b/testdata/src/go.uber.org/zap/zap.go new file mode 100644 index 0000000..ba9c9ed --- /dev/null +++ b/testdata/src/go.uber.org/zap/zap.go @@ -0,0 +1,63 @@ +package zap + +type Field struct{} + +type Logger struct{} + +type SugaredLogger struct{} + +func NewNop() *Logger { + return &Logger{} +} + +func String(string, string) Field { + return Field{} +} + +func (*Logger) Debug(string, ...Field) {} + +func (*Logger) Info(string, ...Field) {} + +func (*Logger) Warn(string, ...Field) {} + +func (*Logger) Error(string, ...Field) {} + +func (logger *Logger) With(...Field) *Logger { + return logger +} + +func (*Logger) Sugar() *SugaredLogger { + return &SugaredLogger{} +} + +func (*SugaredLogger) Debugw(string, ...any) {} + +func (*SugaredLogger) Infow(string, ...any) {} + +func (*SugaredLogger) Warnw(string, ...any) {} + +func (*SugaredLogger) Errorw(string, ...any) {} + +func (*SugaredLogger) Debug(...any) {} + +func (*SugaredLogger) Info(...any) {} + +func (*SugaredLogger) Warn(...any) {} + +func (*SugaredLogger) Error(...any) {} + +func (*SugaredLogger) Debugf(string, ...any) {} + +func (*SugaredLogger) Infof(string, ...any) {} + +func (*SugaredLogger) Warnf(string, ...any) {} + +func (*SugaredLogger) Errorf(string, ...any) {} + +func (*SugaredLogger) Debugln(...any) {} + +func (*SugaredLogger) Infoln(...any) {} + +func (*SugaredLogger) Warnln(...any) {} + +func (*SugaredLogger) Errorln(...any) {} diff --git a/testdata/src/mixedcases/mixedcases.go b/testdata/src/mixedcases/mixedcases.go new file mode 100644 index 0000000..cdeffd4 --- /dev/null +++ b/testdata/src/mixedcases/mixedcases.go @@ -0,0 +1,48 @@ +package mixedcases + +import ( + "context" + "io" + "log/slog" + + "go.uber.org/zap" +) + +var ( + mixedCtx = context.Background() + mixedSlog = slog.New(slog.NewTextHandler(io.Discard, nil)) + mixedZap = zap.NewNop() + mixedSugar = mixedZap.Sugar() +) + +func validBoundaryCases() { + slog.Info("oauth flow started") + slog.Warn("tokenizer warmup complete") + mixedSlog.Error("author cache miss") + mixedZap.Info("secretary rotation complete") + mixedSugar.Infow("apikeys cache synced", "component", "auth") +} + +func invalidBoundaryCases() { + slog.Info("auth flow started") // want `log message may contain sensitive data` + mixedSlog.Warn("token refresh failed") // want `log message may contain sensitive data` + mixedZap.Error("api_key rotation failed") // want `log message may contain sensitive data` + mixedSugar.Infow("password reset issued", "component", "auth") // want `log message may contain sensitive data` +} + +func skippedDynamicConcatenationCases() { + secret := "hidden" + status := "failed" + + slog.Info("password: " + secret) + mixedSlog.Warn("token refresh " + status) + mixedZap.Info("auth state " + status) + mixedSugar.Infow("secret value "+secret, "component", "auth") +} + +func multipleViolationCases() { + slog.Info("Password leaked!") // want `log message must start with a lowercase letter` `log message must not contain special characters or emoji` `log message may contain sensitive data` + mixedSlog.Log(mixedCtx, slog.LevelError, "Тoken leaked?") // want `log message must start with a lowercase letter` `log message must be in English \(ASCII only\)` `log message must not contain special characters or emoji` + mixedZap.Warn("Secret rotation failed!") // want `log message must start with a lowercase letter` `log message must not contain special characters or emoji` `log message may contain sensitive data` + mixedSugar.Errorw("Auth token leaked?", "component", "auth") // want `log message must start with a lowercase letter` `log message must not contain special characters or emoji` `log message may contain sensitive data` +} diff --git a/testdata/src/slogcases/slogcases.go b/testdata/src/slogcases/slogcases.go new file mode 100644 index 0000000..7837de4 --- /dev/null +++ b/testdata/src/slogcases/slogcases.go @@ -0,0 +1,73 @@ +package slogcases + +import ( + "context" + "fmt" + "io" + "log/slog" +) + +var ( + slogCtx = context.Background() + slogLogger = slog.New(slog.NewTextHandler(io.Discard, nil)) +) + +func makeMessage(suffix string) string { + return "starting " + suffix +} + +func validTopLevelCalls() { + slog.Info("starting server") + slog.Warn("cache miss") + slog.Error("request failed") + slog.Debug("retry scheduled") +} + +func invalidTopLevelCalls() { + slog.Info("Starting server") // want `log message must start with a lowercase letter` + slog.Warn("ошибка подключения") // want `log message must be in English \(ASCII only\)` + slog.Error("server started!") // want `log message must not contain special characters or emoji` + slog.Debug("password rotation failed") // want `log message may contain sensitive data` +} + +func validLoggerMethodCalls() { + slogLogger.Info("connection established") + slogLogger.WarnContext(slogCtx, "worker stopped") + slogLogger.Log(slogCtx, slog.LevelInfo, "query executed") + slogLogger.LogAttrs(slogCtx, slog.LevelInfo, "request completed", slog.String("component", "api")) +} + +func invalidLoggerMethodCalls() { + slogLogger.Info("Connection established") // want `log message must start with a lowercase letter` + slogLogger.WarnContext(slogCtx, "запрос завершен") // want `log message must be in English \(ASCII only\)` + slogLogger.Log(slogCtx, slog.LevelWarn, "worker stopped?") // want `log message must not contain special characters or emoji` + slogLogger.LogAttrs(slogCtx, slog.LevelError, "auth secret rotated", slog.String("component", "worker")) // want `log message may contain sensitive data` +} + +func validContextAndChainedCalls() { + slog.InfoContext(slogCtx, "shutdown complete") + slog.Log(slogCtx, slog.LevelInfo, "queue drained") + slog.LogAttrs(slogCtx, slog.LevelInfo, "batch processed", slog.String("job", "sync")) + slogLogger.With("component", "api").Info("request started") + slogLogger.WithGroup("db").ErrorContext(slogCtx, "connection failed") +} + +func invalidContextAndChainedCalls() { + slog.InfoContext(slogCtx, "Shutdown complete") // want `log message must start with a lowercase letter` + slog.Log(slogCtx, slog.LevelInfo, "данные обновлены") // want `log message must be in English \(ASCII only\)` + slog.LogAttrs(slogCtx, slog.LevelInfo, "batch processed...", slog.String("job", "sync")) // want `log message must not contain special characters or emoji` + slogLogger.With("component", "api").Info("api_key rotated") // want `log message may contain sensitive data` + slogLogger.WithGroup("db").ErrorContext(slogCtx, "Token refresh failed") // want `log message must start with a lowercase letter` `log message may contain sensitive data` +} + +func skippedDynamicCalls() { + message := "Starting server" + secret := "hidden" + + slog.Info(message) + slog.Info(fmt.Sprintf("starting %s", "server")) + slog.Info(makeMessage("server")) + slog.Info("password: " + secret) + slogLogger.Info(message) + slogLogger.With("component", "api").Info(fmt.Sprintf("request %s", "started")) +} diff --git a/testdata/src/zapcases/zapcases.go b/testdata/src/zapcases/zapcases.go new file mode 100644 index 0000000..027960c --- /dev/null +++ b/testdata/src/zapcases/zapcases.go @@ -0,0 +1,61 @@ +package zapcases + +import ( + "fmt" + + "go.uber.org/zap" +) + +var ( + zapLogger = zap.NewNop() + sugarLogger = zapLogger.Sugar() +) + +func buildMessage(suffix string) string { + return "starting " + suffix +} + +func validLoggerCalls() { + zapLogger.Debug("worker started") + zapLogger.Info("request completed", zap.String("component", "api")) + zapLogger.Warn("cache miss") + zapLogger.Error("connection failed") + zapLogger.With(zap.String("component", "db")).Info("query executed") +} + +func invalidLoggerCalls() { + zapLogger.Info("Request completed") // want `log message must start with a lowercase letter` + zapLogger.Warn("ошибка запроса") // want `log message must be in English \(ASCII only\)` + zapLogger.Error("request failed!") // want `log message must not contain special characters or emoji` + zapLogger.Debug("secret rotation failed") // want `log message may contain sensitive data` + zapLogger.With(zap.String("component", "db")).Info("Token refresh failed") // want `log message must start with a lowercase letter` `log message may contain sensitive data` +} + +func validSugaredCalls() { + sugarLogger.Debugw("worker started", "component", "api") + sugarLogger.Infow("request completed", "component", "api") + sugarLogger.Warnw("cache miss", "component", "cache") + sugarLogger.Errorw("connection failed", "component", "db") + zapLogger.Sugar().Infow("job completed", "job", "sync") +} + +func invalidSugaredCalls() { + sugarLogger.Infow("Request completed", "component", "api") // want `log message must start with a lowercase letter` + sugarLogger.Warnw("ошибка запроса", "component", "api") // want `log message must be in English \(ASCII only\)` + sugarLogger.Errorw("request failed?", "component", "db") // want `log message must not contain special characters or emoji` + sugarLogger.Debugw("auth token rotated", "component", "auth") // want `log message may contain sensitive data` + zapLogger.Sugar().Infow("Api_key rotated", "component", "auth") // want `log message must start with a lowercase letter` `log message may contain sensitive data` +} + +func skippedPrintStyleCalls() { + message := "Request completed" + secret := "hidden" + + sugarLogger.Info(message) + sugarLogger.Infof("request %s", "completed") + sugarLogger.Infoln("request completed") + sugarLogger.Info("password:", secret) + sugarLogger.Info(fmt.Sprintf("request %s", "completed")) + sugarLogger.Info(buildMessage("server")) + zapLogger.Sugar().Info(message) +}