GitHub リポジトリのスキャン結果を Firestore に保存するためのリポジトリ層を実装する。定期スキャンの実装に向けて、リポジトリの基本情報とブランチごとのスキャン結果を階層的に管理する。
- Firestore に以下のコレクション構造でデータを保存:
repo/{repoID}: リポジトリの基本情報(デフォルトブランチ、インストールID等)repo/{repoID}/branch/{branchName}: 各ブランチの最新スキャン結果、スキャンID等repo/{repoID}/branch/{branchName}/target/{targetID}: スキャン対象(Trivyの Result.Target に対応)repo/{repoID}/branch/{branchName}/target/{targetID}/vulnerability/{vulnID}: 各脆弱性の詳細情報
- リポジトリ情報の CRUD 操作
- リポジトリ基本情報の作成・読取・更新・削除
- ブランチスキャン結果の作成・読取・更新・削除
- 脆弱性情報の作成・読取・更新・削除
- 脆弱性情報の保存
- Trivyの脆弱性情報をほぼ完全な形で保存
- Description、References、CVSS(元の構造を保持)、PublishedDate/LastModifiedDate を含む
- 各脆弱性を個別ドキュメントとして保存(Firestoreの1MB制限を回避)
- 脆弱性ステータス管理
Active: 最新スキャンで検出された脆弱性Fixed: 過去に検出されたが最新スキャンで検出されなくなった脆弱性- 既存脆弱性は削除せず、ステータス更新で履歴を保持
- バッチ操作のサポート
- 複数の脆弱性を一括で読み込み
- 複数の脆弱性を一括で書き込み(Firestoreのバッチ書き込みを活用)
- 複数の脆弱性ステータスを一括更新
- メモリ実装とFirestore実装の両方を提供(テスト用途と本番用途)
- モック生成に対応したインターフェース設計
- CLI統合
scanコマンド: Firestoreへの保存をオプショナルに対応serveコマンド: webhook処理でFirestoreへの保存をオプショナルに対応- Firestore設定がない場合でも正常動作(BigQueryのみに保存)
- パフォーマンス要件
- Firestore の読み書き操作は適切にタイムアウト設定を行う
- バッチ操作を活用して効率的な更新を実現(最大500件/バッチ)
- 大量の脆弱性読み込み時はページネーションを考慮
- セキュリティ要件
- Firestore への接続は適切な認証を使用
- サービスアカウントの impersonate をサポート
- テスタビリティ
- インターフェースベースの設計でモック化を容易に
- メモリ実装による高速なユニットテスト実行
- 既存の
tmp/hecatoncheires/pkg/repositoryと同様の設計パターンを踏襲 - 既存のドメインモデルとの整合性を保つ
- goerr v2 を使用したエラーハンドリング
- gt を使用したテストフレームワーク
- Firestore 制限:
- 1ドキュメントあたり最大1MB(脆弱性を個別ドキュメント化することで回避)
- バッチ書き込みは最大500件まで
- トランザクションは最大500ドキュメントまで
クリーンアーキテクチャに基づき、以下の層構造で実装:
pkg/domain/interfaces/: リポジトリインターフェース定義pkg/domain/model/: ドメインモデル定義(Scan、Repository、Branch)pkg/repository/: リポジトリ実装(memory、firestore)pkg/domain/mock/: モック生成
// types.CommitSHA: Gitコミットハッシュ
type CommitSHA string
// types.ScanStatus: スキャンステータス
type ScanStatus string
const (
ScanStatusSuccess ScanStatus = "success"
ScanStatusFailure ScanStatus = "failure"
ScanStatusPending ScanStatus = "pending"
)
// types.VulnStatus: 脆弱性ステータス
type VulnStatus string
const (
VulnStatusActive VulnStatus = "active"
VulnStatusFixed VulnStatus = "fixed"
)// Repository: GitHub リポジトリの基本情報
type Repository struct {
ID types.GitHubRepoID // "{owner}/{name}" 形式
Owner string
Name string
DefaultBranch types.BranchName
InstallationID int64
CreatedAt time.Time
UpdatedAt time.Time
}
// Branch: ブランチのスキャン情報
type Branch struct {
Name types.BranchName
LastScanID types.ScanID
LastScanAt time.Time
LastCommitSHA types.CommitSHA
Status types.ScanStatus
CreatedAt time.Time
UpdatedAt time.Time
}
// Target: スキャン対象の情報(Trivyの Result に対応)
type Target struct {
ID types.TargetID // Target文字列をハッシュ化したID
Target string // Trivyの Result.Target(例: "go.mod", "package-lock.json", "alpine:3.14")
Class string // Trivyの Result.Class(例: "os-pkgs", "lang-pkgs")
Type string // Trivyの Result.Type(例: "alpine", "gomod", "npm")
CreatedAt time.Time
UpdatedAt time.Time
}
// Vulnerability: Firestoreに保存する脆弱性の詳細情報
// Trivyの DetectedVulnerability をベースに、Firestoreに保存する情報を定義
type Vulnerability struct {
ID string // VulnerabilityID (例: CVE-2021-1234)
PkgName string // パッケージ名
PkgPath string // パッケージパス
InstalledVersion string // インストール済みバージョン
FixedVersion string // 修正バージョン
Severity string // CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN
Title string // 脆弱性タイトル
Description string // 脆弱性の説明
References []string // 参照URL一覧
PrimaryURL string // 主要URL
CweIDs []string // CWE IDs
CVSS map[string]CVSS // CVSS情報(元の構造を保持)
PublishedDate string // 公開日
LastModifiedDate string // 最終更新日
Status types.VulnStatus // active, fixed, ignored
CreatedAt time.Time
UpdatedAt time.Time
}
// CVSS: Trivyと同じ構造
type CVSS struct {
V2Vector string
V3Vector string
V2Score float64
V3Score float64
}// ScanRepository: スキャン情報のリポジトリインターフェース
type ScanRepository interface {
// Repository operations
CreateRepository(ctx context.Context, repo *Repository) error
GetRepository(ctx context.Context, repoID types.GitHubRepoID) (*Repository, error)
UpdateRepository(ctx context.Context, repo *Repository) error
ListRepositories(ctx context.Context, installationID int64) ([]*Repository, error)
// Branch operations
CreateOrUpdateBranch(ctx context.Context, repoID types.GitHubRepoID, branch *Branch) error
GetBranch(ctx context.Context, repoID types.GitHubRepoID, branchName types.BranchName) (*Branch, error)
ListBranches(ctx context.Context, repoID types.GitHubRepoID) ([]*Branch, error)
// Target operations
CreateOrUpdateTarget(ctx context.Context, repoID types.GitHubRepoID, branchName types.BranchName, target *Target) error
GetTarget(ctx context.Context, repoID types.GitHubRepoID, branchName types.BranchName, targetID types.TargetID) (*Target, error)
ListTargets(ctx context.Context, repoID types.GitHubRepoID, branchName types.BranchName) ([]*Target, error)
// Vulnerability operations (batch only)
ListVulnerabilities(ctx context.Context, repoID types.GitHubRepoID, branchName types.BranchName, targetID types.TargetID) ([]*Vulnerability, error)
BatchCreateVulnerabilities(ctx context.Context, repoID types.GitHubRepoID, branchName types.BranchName, targetID types.TargetID, vulns []*Vulnerability) error
BatchUpdateVulnerabilityStatus(ctx context.Context, repoID types.GitHubRepoID, branchName types.BranchName, targetID types.TargetID, updates map[string]types.VulnStatus) error
}
// ヘルパー関数: Trivyの脆弱性からVulnerabilityモデルを生成
func NewVulnerability(detected *trivy.DetectedVulnerability) *Vulnerability {
now := time.Now()
// CVSS情報を元の構造のまま変換
cvss := make(map[string]CVSS)
for sourceID, vendorCVSS := range detected.CVSS {
cvss[string(sourceID)] = CVSS{
V2Vector: vendorCVSS.V2Vector,
V3Vector: vendorCVSS.V3Vector,
V2Score: vendorCVSS.V2Score,
V3Score: vendorCVSS.V3Score,
}
}
return &Vulnerability{
ID: detected.VulnerabilityID,
PkgName: detected.PkgName,
PkgPath: detected.PkgPath,
InstalledVersion: detected.InstalledVersion,
FixedVersion: detected.FixedVersion,
Severity: detected.Severity,
Title: detected.Title,
Description: detected.Description,
References: detected.References,
PrimaryURL: detected.PrimaryURL,
CweIDs: detected.CweIDs,
CVSS: cvss, // 元の構造を保持
PublishedDate: detected.PublishedDate,
LastModifiedDate: detected.LastModifiedDate,
Status: types.VulnStatusActive, // 新規検出時はactive
CreatedAt: now,
UpdatedAt: now,
}
}
// ToFirestoreID: owner と repo から Firestore のドキュメントIDを生成
// Firestoreのドキュメント制約: スラッシュは使用不可のためコロンで結合
//
// 一意性の根拠:
// - GitHubのowner名(ユーザー名/組織名)は英数字とハイフン(-)のみ使用可能
// - コロン(:)は使用不可(GitHubの仕様)
// - したがって owner と repo をコロンで結合すれば一意性が保証され、視認性も良い
//
// 例: ("m-mizutani", "octovy") → "m-mizutani:octovy"
func ToFirestoreID(owner, repo string) (string, error) {
// バリデーション: owner と repo が空でないこと
if owner == "" || repo == "" {
return "", goerr.New("owner or repo is empty",
goerr.V("owner", owner),
goerr.V("repo", repo),
)
}
// バリデーション: ":"が含まれていないこと(念のため)
if strings.Contains(owner, ":") || strings.Contains(repo, ":") {
return "", goerr.New("owner or repo contains invalid character ':'",
goerr.V("owner", owner),
goerr.V("repo", repo),
)
}
return owner + ":" + repo, nil
}- GitHub Webhook 受信 → UseCase が Trivy スキャン実行
- スキャン結果を BigQuery に保存(詳細な脆弱性情報を含む全データ)
- ScanRepository 呼び出し:
a. リポジトリ基本情報の確認・作成(なければ作成)
b. Trivy の Results を反復処理(各 Result が Target に対応):
- Target 情報を作成・更新(CreateOrUpdateTarget)
- Target.ID は Target 文字列の SHA256 ハッシュ c. 各 Target に対して:
- 既存の脆弱性リストを取得(ListVulnerabilities)
- 新規スキャン結果と既存脆弱性を比較:
- 新規検出: 既存にない脆弱性 →
Status: Activeで新規作成 - 継続検出: 既存にある脆弱性 → ステータス維持(既存が
FixedならActiveに戻す) - 修正済み: 既存にあるが新規スキャンにない →
Status: Fixedに更新
- 新規検出: 既存にない脆弱性 →
- バッチ操作で効率的に処理:
- 新規脆弱性をバッチ作成(BatchCreateVulnerabilities、500件ずつ)
- 修正済み脆弱性をバッチステータス更新(BatchUpdateVulnerabilityStatus) d. Branch ドキュメントを更新(LastScanID、LastScanAt、LastCommitSHA、Status等)
- 定期スキャン時は Firestore から Branch 情報を取得して最新状態を確認
- UI等で脆弱性一覧表示時:
- ListTargets でブランチの全 Target を取得
- 各 Target に対して ListVulnerabilities で脆弱性を取得(ステータスでフィルタ可能)
┌─────────┐ 新規検出 ┌────────┐
│ (なし) │ ────────> │ Active │
└─────────┘ └────────┘
│ │
修正確認 │ │ 再検出
│ │
▼ │
┌───────┐
│ Fixed │
└───────┘
octovy/
├── repo/
│ ├── {firestoreRepoID}/ # Document: Repository基本情報
│ │ │ # firestoreRepoID = owner:repo (例: "m-mizutani:octovy")
│ │ │ # - id (元の "{owner}/{repo}" 形式を保持)
│ │ │ # - owner, name, defaultBranch, installationID
│ │ │ # - createdAt, updatedAt
│ │ ├── branch/ # Subcollection
│ │ │ ├── {branchName}/ # Document: Branch情報
│ │ │ │ │ # - name, lastScanID, lastScanAt, lastCommitSHA (types.CommitSHA)
│ │ │ │ │ # - status (types.ScanStatus: success/failure/pending)
│ │ │ │ │ # - createdAt, updatedAt
│ │ │ │ ├── target/ # Subcollection
│ │ │ │ │ ├── {targetID}/ # Document: Target情報(Trivyの Result に対応)
│ │ │ │ │ │ │ # - id (Target文字列のハッシュ)
│ │ │ │ │ │ │ # - target (例: "go.mod", "alpine:3.14")
│ │ │ │ │ │ │ # - class (例: "os-pkgs", "lang-pkgs")
│ │ │ │ │ │ │ # - type (例: "alpine", "gomod", "npm")
│ │ │ │ │ │ │ # - createdAt, updatedAt
│ │ │ │ │ │ ├── vulnerability/ # Subcollection
│ │ │ │ │ │ │ ├── {vulnID} # Document: Vulnerability詳細
│ │ │ │ │ │ │ │ # - id (CVE-2021-1234等)
│ │ │ │ │ │ │ │ # - pkgName, installedVersion, fixedVersion
│ │ │ │ │ │ │ │ # - severity, title, description
│ │ │ │ │ │ │ │ # - references[], primaryURL, cweIDs[]
│ │ │ │ │ │ │ │ # - cvss (map[string]CVSS)
│ │ │ │ │ │ │ │ # - publishedDate, lastModifiedDate
│ │ │ │ │ │ │ │ # - status (Active/Fixed)
│ │ │ │ │ │ │ │ # - createdAt, updatedAt
注記:
- repoID は内部的に
{owner}/{repo}形式だが、Firestore のドキュメントIDとしてはowner:repoに変換- 一意性保証: GitHubのowner名はコロン使用不可(英数字とハイフンのみ)のため、
/を:に変換すれば衝突しない - 視認性: コロンは区切り文字として視認性が高く、
owner:repoの形式は理解しやすい - 例:
m-mizutani/octovy→m-mizutani:octovy
- 一意性保証: GitHubのowner名はコロン使用不可(英数字とハイフンのみ)のため、
- targetID は Target 文字列(例: "go.mod", "alpine:3.14")を SHA256 ハッシュ化したもの
- Firestore のドキュメントIDとして安全な文字列を使用
- 衝突の可能性は事実上ゼロ
- 各脆弱性は個別ドキュメントとして保存(1MB制限を回避)
- 脆弱性は削除せず、ステータス更新で履歴管理(Active/Fixedの遷移を追跡可能)
- Target層を追加することで、Trivyのスキャン結果の構造を正確に反映
- 1つのブランチに複数のTarget(go.mod, package-lock.json等)が存在
- 各Targetに対して個別に脆弱性を管理
- スキャン結果の完全な詳細はBigQueryにも保存(バックアップと高度な分析用)
- Firestoreには定期スキャンとUI表示に必要な情報を保存
- goerr v2 を使用した構造化エラー
- NotFound、AlreadyExists、ValidationError などのドメイン固有エラー
- Firestore のネットワークエラーは適切にラップ
- 新規作成:
-
pkg/domain/types/vuln_status.go- VulnStatus 型定義(Active, Fixed) -
pkg/domain/types/github.go- GitHubRepoID, BranchName 型定義を追加 -
pkg/domain/interfaces/scan_repository.go- リポジトリインターフェース -
pkg/domain/model/repository.go- Repository モデル -
pkg/domain/model/branch.go- Branch モデル -
pkg/domain/model/target.go- Target モデル -
pkg/domain/model/vulnerability.go- Vulnerability モデルと CVSS 構造体 -
pkg/repository/errors.go- 共通エラー定義 -
pkg/repository/memory/memory.go- メモリ実装のエントリポイント -
pkg/repository/memory/scan.go- メモリ実装のスキャンリポジトリ -
pkg/repository/firestore/firestore.go- Firestore実装のエントリポイント -
pkg/repository/firestore/scan.go- Firestore実装のスキャンリポジトリ -
pkg/repository/memory/scan_test.go- メモリ実装のテスト -
pkg/repository/firestore/scan_test.go- Firestore実装のテスト -
pkg/domain/mock/scan_repository_mock.go- モック(自動生成)
-
- 修正: なし(既存コードへの影響を最小化)
- Step 1: ドメインモデルとインターフェース定義
pkg/domain/types/vuln_status.goを作成(VulnStatus型: Active, Fixed)pkg/domain/types/github.goに GitHubRepoID, BranchName, TargetID 型を追加pkg/domain/model/repository.goを作成(Repository構造体、タグなし)pkg/domain/model/branch.goを作成(Branch構造体、タグなし)pkg/domain/model/target.goを作成(Target構造体、タグなし)pkg/domain/model/vulnerability.goを作成(Vulnerability、CVSS構造体、ヘルパー関数、タグなし)pkg/domain/interfaces/scan_repository.goを作成(ScanRepositoryインターフェース)pkg/repository/errors.goを作成(共通エラー定義)
- Step 2: 共通テストヘルパーの作成
pkg/repository/testhelper/scan_repository_test.goを作成- Memory/Firestore両実装で使用する共通テスト関数を定義
- Step 3: メモリ実装の作成
pkg/repository/memory/memory.goを作成(エントリポイント)pkg/repository/memory/scan.goでScanRepository実装- map ベースのインメモリストレージ
- Repository:
map[types.GitHubRepoID]*model.Repository - Branch:
map[types.GitHubRepoID]map[types.BranchName]*model.Branch - Target:
map[types.GitHubRepoID]map[types.BranchName]map[types.TargetID]*model.Target - Vulnerability:
map[types.GitHubRepoID]map[types.BranchName]map[types.TargetID]map[string]*model.Vulnerability - バッチステータス更新メソッドの実装
pkg/repository/memory/scan_test.goでユニットテスト実装- 共通テストヘルパーを使用
- Step 4: Firestore実装の作成
pkg/repository/firestore/firestore.goを作成(エントリポイント)pkg/repository/firestore/scan.goでScanRepository実装- コレクション:
repo/{firestoreRepoID}/branch/{branchName}/target/{targetID}/vulnerability/{vulnID} - バッチ書き込み実装(500件ずつ)
- バッチステータス更新実装
- ToFirestoreID によるID変換(owner, repo → owner:repo)
- コレクション:
pkg/repository/firestore/scan_test.goで統合テスト実装- 環境変数チェック(TEST_FIRESTORE_PROJECT_ID, TEST_FIRESTORE_DATABASE_ID)
- 共通テストヘルパーを使用(Memory実装と同じテスト)
- Step 5: モック生成とテスト完成
//go:generate moqディレクティブを追加task genでモックを生成- すべてのテストを実行して動作確認
- Step 6: CLI統合(オプショナル対応)
pkg/cli/scan.goを修正- Firestore関連のフラグを追加(project-id, database-id, collection-prefix等)
- フラグが設定されている場合のみFirestore接続を初期化
- 設定がない場合はBigQueryのみに保存
pkg/cli/serve.goを修正- Firestore関連のフラグを追加(project-id, database-id, collection-prefix等)
- フラグが設定されている場合のみFirestore接続を初期化
- webhook処理でFirestore保存をスキップ可能に
- UseCase層の修正
pkg/usecase/scan_github_repo.goにFirestore保存処理を追加- ScanRepositoryがnilの場合はスキップするロジックを追加
- 環境変数のドキュメント更新
OCTOVY_FIRESTORE_PROJECT_ID: Firestore project ID(オプショナル)OCTOVY_FIRESTORE_DATABASE_ID: Firestore database ID(オプショナル)OCTOVY_FIRESTORE_COLLECTION_PREFIX: Collection prefix(オプショナル、デフォルト: "")
- 共通テストの原則: Memory実装とFirestore実装は必ず同じテストケースを実行
- テストヘルパー関数: 共通のテストロジックを関数化し、両実装で再利用
- 環境変数による実Firestore接続:
TEST_FIRESTORE_PROJECT_IDとTEST_FIRESTORE_DATABASE_IDが設定されている場合のみ実Firestoreに接続- 未設定の場合はFirestoreテストをスキップ(Memory実装のみテスト)
- CI環境では環境変数を設定して実Firestoreでテスト実行
-
共通テストヘルパーの作成
pkg/repository/testhelper/scan_repository_test.goに共通テスト関数を定義TestRepositoryCRUD(t *testing.T, repo ScanRepository)- Repository操作テストTestBranchCRUD(t *testing.T, repo ScanRepository)- Branch操作テストTestVulnerabilityBatchOps(t *testing.T, repo ScanRepository)- Vulnerability操作テストTestVulnerabilityStatusUpdate(t *testing.T, repo ScanRepository)- ステータス更新テスト
-
Memory実装のテスト
pkg/repository/memory/scan_test.go- 共通テストヘルパーをMemory実装に適用
- 並行アクセス時の動作確認(Memory固有)
-
Firestore実装のテスト
pkg/repository/firestore/scan_test.go- 環境変数チェック:
TEST_FIRESTORE_PROJECT_ID、TEST_FIRESTORE_DATABASE_ID - 未設定時は
t.Skip("Firestore credentials not configured") - 設定時のみ共通テストヘルパーをFirestore実装に適用
- ID変換テスト(ToFirestoreID/FromFirestoreID)
- バッチ書き込み500件制限の確認
- クリーンアップ処理(テスト後のデータ削除)
-
統合テスト
- NewVulnerability関数のテスト
- Trivyの DetectedVulnerability から正しく変換
- CVSS情報を元の構造(map[string]CVSS)で保持
- Status初期値が Active であることを確認
- 必須フィールドの検証
- スキャン結果処理のシナリオテスト
- 新規検出 → Active
- 継続検出 → ステータス維持
- Fixed → Active への再検出
- 修正済み → Fixed への遷移
- NewVulnerability関数のテスト
// pkg/repository/memory/scan_test.go
func TestMemoryScanRepository(t *testing.T) {
repo := New()
testhelper.TestRepositoryCRUD(t, repo)
testhelper.TestBranchCRUD(t, repo)
testhelper.TestVulnerabilityBatchOps(t, repo)
testhelper.TestVulnerabilityStatusUpdate(t, repo)
}
// pkg/repository/firestore/scan_test.go
func TestFirestoreScanRepository(t *testing.T) {
projectID := os.Getenv("TEST_FIRESTORE_PROJECT_ID")
databaseID := os.Getenv("TEST_FIRESTORE_DATABASE_ID")
if projectID == "" || databaseID == "" {
t.Skip("Firestore credentials not configured (TEST_FIRESTORE_PROJECT_ID, TEST_FIRESTORE_DATABASE_ID)")
}
repo, err := New(context.Background(), projectID, databaseID)
gt.NoError(t, err)
defer repo.Close()
// Memory実装と同じテストを実行
testhelper.TestRepositoryCRUD(t, repo)
testhelper.TestBranchCRUD(t, repo)
testhelper.TestVulnerabilityBatchOps(t, repo)
testhelper.TestVulnerabilityStatusUpdate(t, repo)
}- 環境変数の追加
- 本番環境:
OCTOVY_FIRESTORE_PROJECT_ID- Firestore プロジェクトIDOCTOVY_FIRESTORE_DATABASE_ID- Firestore データベースID(デフォルト:(default))
- 本番環境:
- ドキュメントの更新
- CLAUDE.md に新しいリポジトリ層の説明を追加
- CLAUDE.md にFirestoreテストのパターンを追加
- CI/CDパイプラインの確認 (要:CI環境での設定作業)
TEST_FIRESTORE_PROJECT_IDとTEST_FIRESTORE_DATABASE_IDを設定して実Firestoreでテスト- テスト用Firestore環境の準備
- 注記: コード実装は完了。CI環境でのFirestore認証情報設定が必要