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)
+ }
+}