From a85c95f21770091ac4922fb47d55479af2cf7877 Mon Sep 17 00:00:00 2001 From: Nobuo Miura <84451944+nobuo-miura@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:28:22 +0900 Subject: [PATCH] Initialize project infrastructure, add tests, and update dependencies - Set up GitHub Actions for automated releases, Dependabot, and linting. - Add comprehensive unit tests for core functionalities and built-in rules. - Update Go modules, including `go-github`, and refine API client initialization. - Introduce issue/PR templates and contribution guidelines (`CONTRIBUTING.md`, `SECURITY.md`). --- .github/ISSUE_TEMPLATE/bug_report.yml | 39 ++++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 22 +++ .github/PULL_REQUEST_TEMPLATE.md | 14 ++ .github/dependabot.yml | 14 ++ .github/workflows/release.yml | 34 ++++ .golangci.yml | 27 +++ .goreleaser.yml | 49 +++++ CONTRIBUTING.md | 47 +++++ SECURITY.md | 21 +++ cmd/secretlens/scan.go | 18 +- go.mod | 6 +- go.sum | 11 +- internal/baseline/baseline_test.go | 60 ++++++ internal/detector/verifier/verifier_test.go | 49 +++++ internal/org/audit.go | 12 +- internal/org/audit_test.go | 58 ++++++ internal/reporter/github/github.go | 17 +- internal/reporter/github/github_test.go | 62 ++++++ internal/reporter/html/html_test.go | 65 +++++++ internal/reporter/sarif/sarif_test.go | 56 ++++++ internal/reporter/slack/slack_test.go | 84 +++++++++ internal/scanner/cilog/github_actions.go | 16 +- internal/scanner/cilog/gitlab_ci_test.go | 78 ++++++++ internal/scanner/envfile/envfile_test.go | 102 ++++++++++ internal/scanner/scanner_test.go | 197 ++++++++++++++++++++ rules/rules_test.go | 45 +++++ 27 files changed, 1176 insertions(+), 32 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/release.yml create mode 100644 .golangci.yml create mode 100644 .goreleaser.yml create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 internal/baseline/baseline_test.go create mode 100644 internal/detector/verifier/verifier_test.go create mode 100644 internal/org/audit_test.go create mode 100644 internal/reporter/github/github_test.go create mode 100644 internal/reporter/html/html_test.go create mode 100644 internal/reporter/sarif/sarif_test.go create mode 100644 internal/reporter/slack/slack_test.go create mode 100644 internal/scanner/cilog/gitlab_ci_test.go create mode 100644 internal/scanner/envfile/envfile_test.go create mode 100644 internal/scanner/scanner_test.go create mode 100644 rules/rules_test.go diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..1662b89 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,39 @@ +name: Bug report +description: Report a bug in SecretLens +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for reporting! For security vulnerabilities, please use + [private reporting](https://github.com/nobuo-miura/SecretLens/security/advisories/new) instead. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Describe the bug, including the command you ran and the output. + placeholder: | + Command: secretlens scan --source=docker --image=... + Expected: ... + Actual: ... + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Minimal steps to reproduce. If a specific file/pattern triggers it, include a redacted sample (never paste real secrets). + validations: + required: true + - type: input + id: version + attributes: + label: SecretLens version + placeholder: e.g. v0.1.0 or commit hash + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + placeholder: e.g. macOS 15 / Go 1.26 / arm64 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0d4d697 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://github.com/nobuo-miura/SecretLens/security/advisories/new + about: Please report security vulnerabilities privately. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..8d4ec3a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,22 @@ +name: Feature request +description: Suggest a new feature or detection rule +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: Describe the use case or gap this feature addresses. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: How should it work? For new detection rules, describe the secret format (with a fake example) and suggested severity. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1b7f91a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +## Summary + + + +## Changes + +- + +## Checklist + +- [ ] `go test ./...` passes +- [ ] `golangci-lint run` reports no new warnings +- [ ] Tests added/updated for behavior changes +- [ ] Test fixtures use clearly fake secrets (no real credentials) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..85d2470 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + groups: + go-dependencies: + patterns: + - "*" + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b3f9e9b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - name: Run tests + run: go test ./... + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..a97c1d4 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,27 @@ +version: "2" + +linters: + default: standard + enable: + - misspell + - unconvert + - unparam + - gocritic + - revive + settings: + revive: + rules: + - name: exported + disabled: true + exclusions: + rules: + # テストコードでは緩めに + - path: _test\.go + linters: + - unparam + - gocritic + +formatters: + enable: + - gofmt + - goimports diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..ff0fb5f --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,49 @@ +version: 2 + +project_name: secretlens + +before: + hooks: + - go mod tidy + +builds: + - id: secretlens + main: ./cmd/secretlens + binary: secretlens + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + ldflags: + - -s -w + +archives: + - formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + +checksum: + name_template: checksums.txt + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" + +release: + draft: false + prerelease: auto diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a5c4aca --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# Contributing to SecretLens + +Thank you for your interest in contributing! Contributions of all kinds are welcome: bug reports, detection rules, documentation, and code. + +## Getting Started + +```bash +git clone https://github.com/nobuo-miura/SecretLens.git +cd SecretLens +make build # builds bin/secretlens +go test ./... +``` + +Requirements: + +- Go (version pinned in `go.mod`) +- `golangci-lint` for linting (`make lint` if available, or `golangci-lint run`) + +## Development Workflow + +1. Fork the repository and create a branch from `main`. +2. Make your changes. Please: + - Run `gofmt` (or rely on your editor's Go tooling). + - Run `golangci-lint run` and fix any new warnings. + - Add or update tests for behavior changes. +3. Run the full test suite: `go test ./...` +4. Open a pull request against `main` with a clear description of the change and its motivation. + +## Adding Detection Rules + +Built-in rules live in [rules/](rules/). When adding a rule: + +- Include realistic **positive** and **negative** test fixtures (use clearly fake secrets, e.g. `AKIAIOSFODNN7EXAMPLE`-style placeholders — never real credentials). +- Keep regexes anchored and specific enough to avoid noisy false positives. +- Set an appropriate severity. + +## Reporting Bugs / Requesting Features + +Use the issue templates. For **security vulnerabilities**, follow [SECURITY.md](SECURITY.md) instead of opening a public issue. + +## Commit Messages + +Write commit messages in English, in imperative mood (e.g. `Add Slack webhook rule`, `Fix entropy threshold off-by-one`). + +## License + +By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..fedb65b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +Only the latest release of SecretLens receives security updates. + +## Reporting a Vulnerability + +Please **do not** open a public issue for security vulnerabilities. + +Instead, report them privately via [GitHub Security Advisories](https://github.com/nobuo-miura/SecretLens/security/advisories/new). + +You can expect an initial response within 7 days. Once the issue is confirmed and fixed, we will publish an advisory and credit the reporter (unless you prefer to remain anonymous). + +## Scope + +SecretLens is a secret *detection* tool. False negatives (secrets it fails to detect) are quality issues, not vulnerabilities — please report those as regular issues. Security reports should cover things like: + +- SecretLens itself leaking scanned secrets (e.g. to logs, telemetry, or network) +- Code execution or path traversal triggered by scanned content +- Vulnerabilities in the verification (`--verify`) network calls diff --git a/cmd/secretlens/scan.go b/cmd/secretlens/scan.go index 38b3712..cd36d72 100644 --- a/cmd/secretlens/scan.go +++ b/cmd/secretlens/scan.go @@ -309,18 +309,23 @@ func scanCILog(ctx context.Context, rules []regex.Rule) ([]finding.Finding, erro var scanErr error go func() { defer close(ch) - if flagRepo != "" { + switch { + case flagRepo != "": parts := strings.SplitN(flagRepo, "/", 2) if len(parts) != 2 { scanErr = fmt.Errorf("--repo は owner/repo 形式で指定してください") return } - s := cilog.NewGitHubActionsScanner(token, parts[0], parts[1]) + s, err := cilog.NewGitHubActionsScanner(token, parts[0], parts[1]) + if err != nil { + scanErr = err + return + } scanErr = s.StreamLogs(ctx, ch) - } else if flagProjectID != "" { + case flagProjectID != "": s := cilog.NewGitLabCIScanner(flagGitLabURL, token, flagProjectID) scanErr = s.StreamLogs(ctx, ch) - } else { + default: scanErr = fmt.Errorf("cilogスキャンには --repo (GitHub) または --project-id (GitLab) が必要です") } }() @@ -366,7 +371,10 @@ func outputFindings(ctx context.Context, findings []finding.Finding, repoPath st if len(parts) != 2 { return fmt.Errorf("--repo は owner/repo 形式で指定してください") } - r := reportgithub.New(token, parts[0], parts[1]) + r, err := reportgithub.New(token, parts[0], parts[1]) + if err != nil { + return err + } if flagGitHubPR > 0 { if err := r.PostPRComment(ctx, flagGitHubPR, findings); err != nil { return err diff --git a/go.mod b/go.mod index d9a9bdf..a09a42e 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ module github.com/nobuo-miura/SecretLens -go 1.26.4 +go 1.26.5 require ( github.com/bmatcuk/doublestar/v4 v4.10.0 - github.com/google/go-github/v72 v72.0.0 + github.com/google/go-github/v89 v89.0.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.36.0 @@ -13,7 +13,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.10 // indirect diff --git a/go.sum b/go.sum index 56b9fee..a2b0a77 100644 --- a/go.sum +++ b/go.sum @@ -3,13 +3,13 @@ github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fT github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM= -github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-github/v89 v89.0.0 h1:35bEK5XoEcF3PZrlVbl9XN63f5BcJRA/UGkxeC9xPg0= +github.com/google/go-github/v89 v89.0.0/go.mod h1:QLcbU0ipeAqQuR5KSg8c2lql4Qk1EwJ2dWz/0rP4Nho= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -25,7 +25,6 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/baseline/baseline_test.go b/internal/baseline/baseline_test.go new file mode 100644 index 0000000..0faf47b --- /dev/null +++ b/internal/baseline/baseline_test.go @@ -0,0 +1,60 @@ +package baseline + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoad_FileNotExist(t *testing.T) { + // 存在しないファイルは空のベースラインを返す(エラーにしない) + b, err := Load(filepath.Join(t.TempDir(), "nonexistent.json")) + require.NoError(t, err) + assert.Empty(t, b.List()) + assert.False(t, b.Contains("deadbeef")) +} + +func TestLoad_InvalidJSON(t *testing.T) { + path := filepath.Join(t.TempDir(), "broken.json") + require.NoError(t, os.WriteFile(path, []byte("{not json"), 0o600)) + + _, err := Load(path) + assert.Error(t, err) +} + +func TestAddContainsList(t *testing.T) { + b, err := Load(filepath.Join(t.TempDir(), "bl.json")) + require.NoError(t, err) + + b.Add("fp1") + b.Add("fp2") + b.Add("fp1") // 重複追加は冪等 + + assert.True(t, b.Contains("fp1")) + assert.True(t, b.Contains("fp2")) + assert.False(t, b.Contains("fp3")) + assert.Len(t, b.List(), 2) +} + +func TestSaveAndReload(t *testing.T) { + path := filepath.Join(t.TempDir(), "bl.json") + b, err := Load(path) + require.NoError(t, err) + + b.Add("fingerprint-abc") + require.NoError(t, b.Save()) + + // 保存したファイルを再読込して内容が一致すること + reloaded, err := Load(path) + require.NoError(t, err) + assert.True(t, reloaded.Contains("fingerprint-abc")) + assert.Len(t, reloaded.List(), 1) + + // パーミッションが0600であること(シークレット情報を含みうるため) + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} diff --git a/internal/detector/verifier/verifier_test.go b/internal/detector/verifier/verifier_test.go new file mode 100644 index 0000000..0d7ceb1 --- /dev/null +++ b/internal/detector/verifier/verifier_test.go @@ -0,0 +1,49 @@ +package verifier + +import ( + "context" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestVerify_Dispatch(t *testing.T) { + ctx := context.Background() + + t.Run("aws はペアが必要なため単体では検証不可", func(t *testing.T) { + r := Verify(ctx, "aws", "AKIAIOSFODNN7EXAMPLE") + assert.False(t, r.Valid) + assert.Contains(t, r.Message, "両方が必要") + }) + + t.Run("空typeは未対応メッセージ", func(t *testing.T) { + r := Verify(ctx, "", "value") + assert.False(t, r.Valid) + assert.Contains(t, r.Message, "対応していません") + }) + + t.Run("未知のtypeはエラーメッセージ", func(t *testing.T) { + r := Verify(ctx, "unknown-service", "value") + assert.False(t, r.Valid) + assert.Contains(t, r.Message, "unknown-service") + }) +} + +func TestSha256Hex(t *testing.T) { + // SHA256("") の既知ベクトル + assert.Equal(t, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + sha256Hex("")) + assert.Equal(t, + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + sha256Hex("hello")) +} + +func TestHmacSHA256(t *testing.T) { + // RFC 4231 Test Case 2: key="Jefe", data="what do ya want for nothing?" + mac := hmacSHA256([]byte("Jefe"), "what do ya want for nothing?") + assert.Equal(t, + "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843", + hex.EncodeToString(mac)) +} diff --git a/internal/org/audit.go b/internal/org/audit.go index 20da6ea..a3af736 100644 --- a/internal/org/audit.go +++ b/internal/org/audit.go @@ -5,14 +5,13 @@ import ( "context" "encoding/base64" "fmt" - "net/http" "os" "os/exec" "path/filepath" "strings" "sync" - gogithub "github.com/google/go-github/v72/github" + gogithub "github.com/google/go-github/v89/github" "golang.org/x/oauth2" "github.com/nobuo-miura/SecretLens/internal/detector/regex" @@ -43,12 +42,15 @@ func AuditOrg(ctx context.Context, opts AuditOptions) ([]RepoResult, error) { opts.Concurrency = 4 } - var hc *http.Client + var clientOpts []gogithub.ClientOptionsFunc if opts.Token != "" { ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: opts.Token}) - hc = oauth2.NewClient(ctx, ts) + clientOpts = append(clientOpts, gogithub.WithHTTPClient(oauth2.NewClient(ctx, ts))) + } + client, err := gogithub.NewClient(clientOpts...) + if err != nil { + return nil, fmt.Errorf("GitHubクライアント生成失敗: %w", err) } - client := gogithub.NewClient(hc) repos, err := listOrgRepos(ctx, client, opts.Org) if err != nil { diff --git a/internal/org/audit_test.go b/internal/org/audit_test.go new file mode 100644 index 0000000..a25de17 --- /dev/null +++ b/internal/org/audit_test.go @@ -0,0 +1,58 @@ +package org + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// initLocalRepo はclone元となるローカルgitリポジトリを作成する +func initLocalRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com", + ) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) + } + run("init", "-q") + // ユーザーのグローバル設定(GPG署名等)に依存しないようにする + run("config", "commit.gpgsign", "false") + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# test\n"), 0o600)) + run("add", ".") + run("commit", "-q", "-m", "init") + return dir +} + +func TestCloneRepo_Local(t *testing.T) { + src := initLocalRepo(t) + dst := filepath.Join(t.TempDir(), "clone") + + require.NoError(t, cloneRepo(context.Background(), src, dst, "")) + assert.FileExists(t, filepath.Join(dst, "README.md")) +} + +func TestCloneRepo_InvalidSource(t *testing.T) { + dst := filepath.Join(t.TempDir(), "clone") + err := cloneRepo(context.Background(), filepath.Join(t.TempDir(), "nonexistent"), dst, "") + require.Error(t, err) + // git stderrの内容がエラーメッセージに含まれること + assert.NotEmpty(t, err.Error()) +} + +func TestAuditOptions_ConcurrencyDefault(t *testing.T) { + // Concurrency未指定(0以下)はAuditOrg内で4に補正される。 + // ネットワークを叩かずに検証できる範囲として、オプション構造体の初期値のみ確認 + opts := AuditOptions{} + assert.Equal(t, 0, opts.Concurrency) +} diff --git a/internal/reporter/github/github.go b/internal/reporter/github/github.go index feb5fea..04c4a37 100644 --- a/internal/reporter/github/github.go +++ b/internal/reporter/github/github.go @@ -3,11 +3,10 @@ package github import ( "context" "fmt" - "net/http" "strings" "time" - gogithub "github.com/google/go-github/v72/github" + gogithub "github.com/google/go-github/v89/github" "golang.org/x/oauth2" "github.com/nobuo-miura/SecretLens/internal/finding" @@ -21,17 +20,21 @@ type Reporter struct { } // New はGitHub Reporterを生成する -func New(token, owner, repo string) *Reporter { - var hc *http.Client +func New(token, owner, repo string) (*Reporter, error) { + var opts []gogithub.ClientOptionsFunc if token != "" { ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) - hc = oauth2.NewClient(context.Background(), ts) + opts = append(opts, gogithub.WithHTTPClient(oauth2.NewClient(context.Background(), ts))) + } + client, err := gogithub.NewClient(opts...) + if err != nil { + return nil, fmt.Errorf("GitHubクライアント生成失敗: %w", err) } return &Reporter{ - client: gogithub.NewClient(hc), + client: client, Owner: owner, Repo: repo, - } + }, nil } // PostPRComment はプルリクエストにスキャン結果をコメントとして投稿する diff --git a/internal/reporter/github/github_test.go b/internal/reporter/github/github_test.go new file mode 100644 index 0000000..2fd03a4 --- /dev/null +++ b/internal/reporter/github/github_test.go @@ -0,0 +1,62 @@ +package github + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/nobuo-miura/SecretLens/internal/finding" +) + +func TestFormatPRComment_Empty(t *testing.T) { + body := formatPRComment(nil) + assert.Contains(t, body, "検出されませんでした") +} + +func TestFormatPRComment_WithFindings(t *testing.T) { + findings := []finding.Finding{ + {RuleID: "aws-key", Severity: finding.SeverityCritical, File: "a.env", Line: 3, Match: "AKIA****MPLE"}, + } + body := formatPRComment(findings) + assert.Contains(t, body, "1 件") + assert.Contains(t, body, "aws-key") + assert.Contains(t, body, "a.env") + assert.Contains(t, body, "AKIA****MPLE") + assert.Contains(t, body, ".secretlens.baseline.json") +} + +func TestFormatCheckRunText(t *testing.T) { + assert.Contains(t, formatCheckRunText(nil), "検出されませんでした") + + text := formatCheckRunText([]finding.Finding{ + {RuleID: "gh-token", Severity: finding.SeverityHigh, File: "ci.yml", Line: 5, Match: "ghp_****abcd"}, + }) + assert.Contains(t, text, "gh-token") + assert.Contains(t, text, "ci.yml:5") +} + +func TestHasHigherThan(t *testing.T) { + low := []finding.Finding{{Severity: finding.SeverityLow}} + high := []finding.Finding{{Severity: finding.SeverityLow}, {Severity: finding.SeverityHigh}} + + assert.False(t, hasHigherThan(nil, finding.SeverityLow)) + assert.False(t, hasHigherThan(low, finding.SeverityLow)) + assert.True(t, hasHigherThan(high, finding.SeverityLow)) + assert.False(t, hasHigherThan(high, finding.SeverityHigh)) + assert.False(t, hasHigherThan(high, finding.SeverityCritical)) +} + +func TestSeverityIcon(t *testing.T) { + assert.Equal(t, "🔴", severityIcon(finding.SeverityCritical)) + assert.Equal(t, "🟠", severityIcon(finding.SeverityHigh)) + assert.Equal(t, "🟡", severityIcon(finding.SeverityMedium)) + assert.Equal(t, "🔵", severityIcon(finding.SeverityLow)) +} + +func TestNew(t *testing.T) { + r, err := New("", "owner", "repo") + assert.NoError(t, err) + assert.Equal(t, "owner", r.Owner) + assert.Equal(t, "repo", r.Repo) + assert.NotNil(t, r.client) +} diff --git a/internal/reporter/html/html_test.go b/internal/reporter/html/html_test.go new file mode 100644 index 0000000..47a746e --- /dev/null +++ b/internal/reporter/html/html_test.go @@ -0,0 +1,65 @@ +package html + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nobuo-miura/SecretLens/internal/finding" +) + +func sampleFindings() []finding.Finding { + return []finding.Finding{ + {ID: "SL-0001", RuleID: "low-rule", Severity: finding.SeverityLow, File: "a.txt", Line: 1}, + {ID: "SL-0002", RuleID: "crit-rule", Severity: finding.SeverityCritical, File: "b.env", Line: 2}, + {ID: "SL-0003", RuleID: "med-rule", Severity: finding.SeverityMedium, File: "c.yml", Line: 3}, + {ID: "SL-0004", RuleID: "high-rule", Severity: finding.SeverityHigh, File: "d.cfg", Line: 4}, + } +} + +func TestBuildTemplateData(t *testing.T) { + data := buildTemplateData(sampleFindings(), "owner/repo") + + assert.Equal(t, "owner/repo", data.RepoName) + assert.Equal(t, 4, data.Summary.Total) + assert.Equal(t, 1, data.Summary.Critical) + assert.Equal(t, 1, data.Summary.High) + assert.Equal(t, 1, data.Summary.Medium) + assert.Equal(t, 1, data.Summary.Low) + + // Severity降順にソートされること + require.Len(t, data.Findings, 4) + assert.Equal(t, "CRITICAL", data.Findings[0].Severity) + assert.Equal(t, "HIGH", data.Findings[1].Severity) + assert.Equal(t, "MEDIUM", data.Findings[2].Severity) + assert.Equal(t, "LOW", data.Findings[3].Severity) +} + +func TestWrite(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, Write(&buf, sampleFindings(), "owner/repo")) + + out := buf.String() + assert.True(t, strings.HasPrefix(out, "")) + assert.Contains(t, out, "owner/repo") + assert.Contains(t, out, "crit-rule") +} + +func TestWrite_EscapesRepoName(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, Write(&buf, nil, ``)) + + // リポジトリ名はHTMLエスケープされ、生の`) +} + +func TestSeverityColor(t *testing.T) { + assert.Equal(t, "#dc2626", severityColor("CRITICAL")) + assert.Equal(t, "#ea580c", severityColor("HIGH")) + assert.Equal(t, "#d97706", severityColor("MEDIUM")) + assert.Equal(t, "#2563eb", severityColor("LOW")) + assert.Equal(t, "#2563eb", severityColor("UNKNOWN")) +} diff --git a/internal/reporter/sarif/sarif_test.go b/internal/reporter/sarif/sarif_test.go new file mode 100644 index 0000000..bc3ce6f --- /dev/null +++ b/internal/reporter/sarif/sarif_test.go @@ -0,0 +1,56 @@ +package sarif + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nobuo-miura/SecretLens/internal/finding" +) + +func TestWrite(t *testing.T) { + findings := []finding.Finding{ + {RuleID: "aws-key", Severity: finding.SeverityCritical, Score: 80, File: "config.env", Line: 3}, + {RuleID: "gh-token", Severity: finding.SeverityMedium, Score: 25, File: "ci.yml", Line: 10}, + } + + var buf bytes.Buffer + require.NoError(t, Write(&buf, findings)) + + // 出力が有効なSARIF JSONであること + var log Log + require.NoError(t, json.Unmarshal(buf.Bytes(), &log)) + assert.Equal(t, "2.1.0", log.Version) + require.Len(t, log.Runs, 1) + assert.Equal(t, "SecretLens", log.Runs[0].Tool.Driver.Name) + + results := log.Runs[0].Results + require.Len(t, results, 2) + assert.Equal(t, "aws-key", results[0].RuleID) + assert.Equal(t, "error", results[0].Level) + assert.Equal(t, "config.env", results[0].Locations[0].PhysicalLocation.ArtifactLocation.URI) + assert.Equal(t, 3, results[0].Locations[0].PhysicalLocation.Region.StartLine) + assert.Equal(t, "warning", results[1].Level) +} + +func TestWrite_Empty(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, Write(&buf, nil)) + + var log Log + require.NoError(t, json.Unmarshal(buf.Bytes(), &log)) + require.Len(t, log.Runs, 1) + // findingsゼロ件でも results は null ではなく空配列になる + assert.NotNil(t, log.Runs[0].Results) + assert.Empty(t, log.Runs[0].Results) +} + +func TestSeverityToLevel(t *testing.T) { + assert.Equal(t, "error", severityToLevel(finding.SeverityCritical)) + assert.Equal(t, "error", severityToLevel(finding.SeverityHigh)) + assert.Equal(t, "warning", severityToLevel(finding.SeverityMedium)) + assert.Equal(t, "note", severityToLevel(finding.SeverityLow)) +} diff --git a/internal/reporter/slack/slack_test.go b/internal/reporter/slack/slack_test.go new file mode 100644 index 0000000..e1ab7d5 --- /dev/null +++ b/internal/reporter/slack/slack_test.go @@ -0,0 +1,84 @@ +package slack + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nobuo-miura/SecretLens/internal/finding" +) + +func TestBuildBlocks_Empty(t *testing.T) { + blocks := buildBlocks(nil, "owner/repo") + require.Len(t, blocks, 3) + assert.Equal(t, "header", blocks[0].Type) + assert.Contains(t, blocks[1].Text.Text, "owner/repo") + assert.Contains(t, blocks[2].Text.Text, "検出されませんでした") +} + +func TestBuildBlocks_WithFindings(t *testing.T) { + findings := []finding.Finding{ + {RuleID: "aws-key", Severity: finding.SeverityCritical, File: "a.env", Line: 1}, + {RuleID: "gh-token", Severity: finding.SeverityLow, File: "b.yml", Line: 2}, + } + blocks := buildBlocks(findings, "owner/repo") + require.Len(t, blocks, 3) + assert.Contains(t, blocks[1].Text.Text, "2 件") + assert.Contains(t, blocks[2].Text.Text, "aws-key") + assert.Contains(t, blocks[2].Text.Text, "a.env:1") +} + +func TestBuildBlocks_LimitsToTen(t *testing.T) { + var findings []finding.Finding + for i := 0; i < 15; i++ { + findings = append(findings, finding.Finding{ + RuleID: fmt.Sprintf("rule-%02d", i), Severity: finding.SeverityHigh, + File: "f.env", Line: i + 1, + }) + } + blocks := buildBlocks(findings, "repo") + require.Len(t, blocks, 3) + text := blocks[2].Text.Text + assert.Contains(t, text, "rule-09") + assert.NotContains(t, text, "rule-10") // 11件目以降は省略 + assert.Contains(t, text, "他 5 件") +} + +func TestNotify(t *testing.T) { + var received payload + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + require.NoError(t, json.Unmarshal(body, &received)) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + err := Notify(srv.URL, []finding.Finding{ + {RuleID: "aws-key", Severity: finding.SeverityCritical, File: "a.env", Line: 1}, + }, "owner/repo") + require.NoError(t, err) + assert.NotEmpty(t, received.Blocks) +} + +func TestNotify_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + err := Notify(srv.URL, nil, "repo") + assert.Error(t, err) +} + +func TestSeverityIcon(t *testing.T) { + assert.Equal(t, "🔴", severityIcon(finding.SeverityCritical)) + assert.Equal(t, "🟠", severityIcon(finding.SeverityHigh)) + assert.Equal(t, "🟡", severityIcon(finding.SeverityMedium)) + assert.Equal(t, "🔵", severityIcon(finding.SeverityLow)) +} diff --git a/internal/scanner/cilog/github_actions.go b/internal/scanner/cilog/github_actions.go index af35e86..5321767 100644 --- a/internal/scanner/cilog/github_actions.go +++ b/internal/scanner/cilog/github_actions.go @@ -8,7 +8,7 @@ import ( "net/http" "strings" - gogithub "github.com/google/go-github/v72/github" + gogithub "github.com/google/go-github/v89/github" "golang.org/x/oauth2" ) @@ -19,17 +19,21 @@ type GitHubActionsScanner struct { } // NewGitHubActionsScanner はGitHub Actionsログスキャナーを生成する -func NewGitHubActionsScanner(token, owner, repo string) *GitHubActionsScanner { - var hc *http.Client +func NewGitHubActionsScanner(token, owner, repo string) (*GitHubActionsScanner, error) { + var opts []gogithub.ClientOptionsFunc if token != "" { ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) - hc = oauth2.NewClient(context.Background(), ts) + opts = append(opts, gogithub.WithHTTPClient(oauth2.NewClient(context.Background(), ts))) + } + client, err := gogithub.NewClient(opts...) + if err != nil { + return nil, fmt.Errorf("GitHubクライアント生成失敗: %w", err) } return &GitHubActionsScanner{ - client: gogithub.NewClient(hc), + client: client, Owner: owner, Repo: repo, - } + }, nil } // LogLine はCIログの1行を表す diff --git a/internal/scanner/cilog/gitlab_ci_test.go b/internal/scanner/cilog/gitlab_ci_test.go new file mode 100644 index 0000000..cef8de9 --- /dev/null +++ b/internal/scanner/cilog/gitlab_ci_test.go @@ -0,0 +1,78 @@ +package cilog + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewGitLabCIScanner_Defaults(t *testing.T) { + s := NewGitLabCIScanner("", "tok", "group/project") + assert.Equal(t, "https://gitlab.com", s.BaseURL) + // プロジェクトIDはURLエスケープされる(group/project → group%2Fproject) + assert.Equal(t, "group%2Fproject", s.ProjectID) + + s2 := NewGitLabCIScanner("https://gitlab.example.com/", "tok", "123") + assert.Equal(t, "https://gitlab.example.com", s2.BaseURL) + assert.Equal(t, "123", s2.ProjectID) +} + +func TestGitLabCIScanner_StreamLogs(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v4/projects/123/jobs", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "test-token", r.Header.Get("PRIVATE-TOKEN")) + _, _ = fmt.Fprint(w, `[{"id": 1, "name": "build"}, {"id": 2, "name": "test"}]`) + }) + mux.HandleFunc("/api/v4/projects/123/jobs/1/trace", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, "line one\nline two\n") + }) + mux.HandleFunc("/api/v4/projects/123/jobs/2/trace", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, "test output\n") + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + s := NewGitLabCIScanner(srv.URL, "test-token", "123") + + ch := make(chan LogLine, 100) + go func() { + defer close(ch) + require.NoError(t, s.StreamLogs(context.Background(), ch)) + }() + + var lines []LogLine + for l := range ch { + lines = append(lines, l) + } + require.Len(t, lines, 3) + assert.Equal(t, "gitlab-ci", lines[0].Source) + assert.Equal(t, "build", lines[0].Job) + assert.Equal(t, 1, lines[0].Line) + assert.Equal(t, "line one", lines[0].Text) + assert.Equal(t, "test", lines[2].Job) + assert.Equal(t, "test output", lines[2].Text) +} + +func TestGitLabCIScanner_StreamLogs_APIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + s := NewGitLabCIScanner(srv.URL, "bad-token", "123") + ch := make(chan LogLine, 1) + err := s.StreamLogs(context.Background(), ch) + assert.Error(t, err) +} + +func TestNewGitHubActionsScanner(t *testing.T) { + s, err := NewGitHubActionsScanner("", "owner", "repo") + require.NoError(t, err) + assert.Equal(t, "owner", s.Owner) + assert.Equal(t, "repo", s.Repo) +} diff --git a/internal/scanner/envfile/envfile_test.go b/internal/scanner/envfile/envfile_test.go new file mode 100644 index 0000000..b626d10 --- /dev/null +++ b/internal/scanner/envfile/envfile_test.go @@ -0,0 +1,102 @@ +package envfile + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScanFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "app.env") + require.NoError(t, os.WriteFile(path, []byte("KEY1=value1\nKEY2=value2\n"), 0o600)) + + lines, err := ScanFile(path) + require.NoError(t, err) + require.Len(t, lines, 2) + assert.Equal(t, "KEY1=value1", lines[0].Text) + assert.Equal(t, 1, lines[0].Line) + assert.Equal(t, "KEY2=value2", lines[1].Text) + assert.Equal(t, 2, lines[1].Line) + assert.Equal(t, path, lines[0].File) +} + +func TestScanFile_NotExist(t *testing.T) { + _, err := ScanFile(filepath.Join(t.TempDir(), "missing.env")) + assert.Error(t, err) +} + +func TestScanDir(t *testing.T) { + dir := t.TempDir() + // 対象: .env / .yaml、非対象: .go + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("A=1\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("b: 2\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0o600)) + + // スキップ対象ディレクトリ内のファイルは無視される + for _, skip := range []string{".git", "node_modules", "vendor"} { + sub := filepath.Join(dir, skip) + require.NoError(t, os.Mkdir(sub, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(sub, "leak.env"), []byte("SECRET=x\n"), 0o600)) + } + + lines, err := ScanDir(dir) + require.NoError(t, err) + + files := map[string]bool{} + for _, l := range lines { + files[filepath.Base(l.File)] = true + } + assert.True(t, files[".env"]) + assert.True(t, files["config.yaml"]) + assert.False(t, files["main.go"]) + assert.False(t, files["leak.env"]) +} + +func TestIsTarget(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {"app.env", true}, + {".env", true}, + {".env.production", true}, // 拡張子を除いた名前が .env のため対象 + {"config.yaml", true}, + {"config.yml", true}, + {"terraform.tfvars", true}, + {"app.properties", true}, + {"nginx.conf", true}, + {"setup.cfg", true}, + {"php.ini", true}, + {"Cargo.toml", true}, + {"credentials", true}, + {"secrets.json", true}, // 拡張子を除いた名前がsecretsのため対象 + {"main.go", false}, + {"README.md", false}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, isTarget(tt.path), "isTarget(%q)", tt.path) + } +} + +func TestIsSensitiveFile(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {".env", true}, + {"path/to/.env", true}, + {"credentials", true}, + {"AWS/credentials", true}, + {"secrets.yaml", true}, + {"secret.txt", true}, + {"SECRETS.YAML", true}, // 大文字小文字を無視 + {"config.yaml", false}, + {"main.go", false}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, IsSensitiveFile(tt.path), "IsSensitiveFile(%q)", tt.path) + } +} diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go new file mode 100644 index 0000000..dd2f3a6 --- /dev/null +++ b/internal/scanner/scanner_test.go @@ -0,0 +1,197 @@ +package scanner + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nobuo-miura/SecretLens/internal/baseline" + "github.com/nobuo-miura/SecretLens/internal/detector/regex" + "github.com/nobuo-miura/SecretLens/internal/finding" +) + +// テスト用のダミーAWSアクセスキー(AWSドキュメントの公式サンプル値) +const fakeAWSKey = "AKIAIOSFODNN7EXAMPLE" + +func testRule(t *testing.T) regex.Rule { + t.Helper() + r := regex.Rule{ + ID: "test-aws-key", + Name: "Test AWS Access Key", + Severity: "CRITICAL", + Pattern: `AKIA[0-9A-Z]{16}`, + } + require.NoError(t, r.Compile()) + return r +} + +func TestRun_NoRules(t *testing.T) { + _, err := Run(Options{}) + assert.Error(t, err) +} + +func TestRun_Envfile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".env"), + []byte("AWS_ACCESS_KEY_ID="+fakeAWSKey+"\n# comment "+fakeAWSKey+"\n"), + 0o600)) + + findings, err := Run(Options{ + Source: "envfile", + RepoPath: dir, + Rules: []regex.Rule{testRule(t)}, + }) + require.NoError(t, err) + // コメント行はスキャン対象外なので1件のみ + require.Len(t, findings, 1) + f := findings[0] + assert.Equal(t, "SL-0001", f.ID) + assert.Equal(t, "test-aws-key", f.RuleID) + assert.Equal(t, "envfile", f.Source) + assert.Equal(t, ".env", f.File) + assert.Equal(t, 1, f.Line) + // マスク済みマッチにraw値全体が含まれないこと + assert.NotEqual(t, fakeAWSKey, f.Match) + assert.Contains(t, f.Match, "****") +} + +func TestRun_BaselineSuppression(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".env"), + []byte("KEY="+fakeAWSKey+"\n"), + 0o600)) + + // 一度スキャンしてfingerprintを取得し、ベースラインに登録 + first, err := Run(Options{Source: "envfile", RepoPath: dir, Rules: []regex.Rule{testRule(t)}}) + require.NoError(t, err) + require.Len(t, first, 1) + + blPath := filepath.Join(dir, "baseline.json") + bl, err := baseline.Load(blPath) + require.NoError(t, err) + bl.Add(first[0].Fingerprint) + require.NoError(t, bl.Save()) + + // ベースライン登録済みの検出は抑制される + second, err := Run(Options{ + Source: "envfile", RepoPath: dir, + Rules: []regex.Rule{testRule(t)}, BaselineFile: blPath, + }) + require.NoError(t, err) + assert.Empty(t, second) +} + +func TestRun_ExcludePattern(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".env"), + []byte("KEY="+fakeAWSKey+"\n"), + 0o600)) + + findings, err := Run(Options{ + Source: "envfile", RepoPath: dir, + Rules: []regex.Rule{testRule(t)}, + Exclude: []string{"*.env", ".env"}, + }) + require.NoError(t, err) + assert.Empty(t, findings) +} + +// initGitRepo はテスト用のgitリポジトリを作りシークレット入りファイルをコミットする +func initGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com", + ) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) + } + run("init", "-q") + // ユーザーのグローバル設定(GPG署名等)に依存しないようにする + run("config", "commit.gpgsign", "false") + require.NoError(t, os.WriteFile( + filepath.Join(dir, "config.txt"), + []byte("aws_key = "+fakeAWSKey+"\n"), + 0o600)) + run("add", ".") + run("commit", "-q", "-m", "add config") + return dir +} + +func TestRun_GitHistory(t *testing.T) { + dir := initGitRepo(t) + + findings, err := Run(Options{ + Source: "git", RepoPath: dir, + Rules: []regex.Rule{testRule(t)}, + }) + require.NoError(t, err) + require.Len(t, findings, 1) + assert.Equal(t, "git", findings[0].Source) + assert.Equal(t, "config.txt", findings[0].File) + assert.NotEmpty(t, findings[0].Commit) +} + +func TestRun_AllDeduplicates(t *testing.T) { + // git履歴とenvfileの両方で検出される同一シークレットは1件にまとまる + dir := initGitRepo(t) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".env"), + []byte("KEY="+fakeAWSKey+"\n"), + 0o600)) + + findings, err := Run(Options{ + Source: "all", RepoPath: dir, + Rules: []regex.Rule{testRule(t)}, + }) + require.NoError(t, err) + + // config.txt(git) と .env(envfile) でファイルが異なるため2件だが、 + // fingerprintベースの重複除去でIDは連番になる + require.Len(t, findings, 2) + assert.Equal(t, "SL-0001", findings[0].ID) + assert.Equal(t, "SL-0002", findings[1].ID) +} + +func TestScoreAndBuild(t *testing.T) { + rule := testRule(t) // CRITICAL: ベース60点 + + t.Run("通常ファイル", func(t *testing.T) { + f := scoreAndBuild(rule, "git", "config.txt", 10, fakeAWSKey, "abc123") + assert.Equal(t, finding.SeverityCritical, f.Severity) + assert.GreaterOrEqual(t, f.Score, 60) + assert.Equal(t, fakeAWSKey, f.Secret) + assert.NotEmpty(t, f.Fingerprint) + }) + + t.Run("センシティブファイルは加点", func(t *testing.T) { + normal := scoreAndBuild(rule, "envfile", "config.txt", 1, fakeAWSKey, "") + sensitive := scoreAndBuild(rule, "envfile", ".env", 1, fakeAWSKey, "") + assert.Equal(t, finding.ScoreSensitiveFile, sensitive.Score-normal.Score) + }) + + t.Run("テストコードは減点", func(t *testing.T) { + normal := scoreAndBuild(rule, "git", "main.go", 1, fakeAWSKey, "") + test := scoreAndBuild(rule, "git", "main_test.go", 1, fakeAWSKey, "") + assert.Equal(t, finding.ScoreTestCode, test.Score-normal.Score) + }) + + t.Run("severityがスコアに反映される", func(t *testing.T) { + low := rule + low.Severity = "LOW" + fLow := scoreAndBuild(low, "git", "config.txt", 1, fakeAWSKey, "") + fCrit := scoreAndBuild(rule, "git", "config.txt", 1, fakeAWSKey, "") + assert.Equal(t, 60, fCrit.Score-fLow.Score) + }) +} diff --git a/rules/rules_test.go b/rules/rules_test.go new file mode 100644 index 0000000..751dec7 --- /dev/null +++ b/rules/rules_test.go @@ -0,0 +1,45 @@ +package rules + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nobuo-miura/SecretLens/internal/detector/regex" +) + +func TestBuiltinRulesLoadAndCompile(t *testing.T) { + // 内蔵ルールが全件バリデーション・コンパイルを通ること + loaded, err := regex.LoadRulesFromFS(FS) + require.NoError(t, err) + assert.NotEmpty(t, loaded) + + ids := map[string]bool{} + for _, r := range loaded { + assert.False(t, ids[r.ID], "ルールID %s が重複", r.ID) + ids[r.ID] = true + } +} + +func TestBuiltinRulesDetectKnownSamples(t *testing.T) { + loaded, err := regex.LoadRulesFromFS(FS) + require.NoError(t, err) + + // 代表的なダミーシークレットがいずれかのルールにマッチすること + // (すべてドキュメント用の公式サンプル値・無効な値) + samples := []string{ + "AKIAIOSFODNN7EXAMPLE", // AWSドキュメントのサンプルアクセスキー + "ghp_" + "0123456789abcdefghijklmnopqrstuvwxyz", // GitHub PAT形式のダミー + } + for _, sample := range samples { + matched := false + for _, r := range loaded { + if len(r.Match(sample)) > 0 { + matched = true + break + } + } + assert.True(t, matched, "サンプル %q がどのルールにもマッチしない", sample) + } +}