├── pkg ├── infra │ ├── gh │ │ ├── comments.json │ │ ├── queries │ │ │ ├── minimize_comment.graphql │ │ │ └── list_comments.graphql │ │ ├── client_test.go │ │ └── client.go │ ├── cs │ │ ├── client_test.go │ │ └── client.go │ ├── trivy │ │ ├── client.go │ │ └── client_test.go │ ├── clients.go │ └── bq │ │ ├── data.json │ │ ├── client_test.go │ │ ├── testdata │ │ └── data.json │ │ └── client.go ├── domain │ ├── model │ │ ├── bigquery.go │ │ ├── github_test.go │ │ ├── schema │ │ │ └── ignore.cue │ │ ├── trivy │ │ │ ├── secret_finding.go │ │ │ ├── detected_license.go │ │ │ ├── misconfiguration.go │ │ │ ├── vulnerability.go │ │ │ ├── report.go │ │ │ └── image.go │ │ ├── usecase.go │ │ ├── testdata │ │ │ └── config │ │ │ │ └── ignore.cue │ │ ├── result.go │ │ ├── config_test.go │ │ ├── github.go │ │ └── config.go │ ├── types │ │ ├── const.go │ │ ├── github.go │ │ ├── types.go │ │ └── error.go │ ├── interfaces │ │ ├── usecase.go │ │ └── infra.go │ ├── mock │ │ ├── cloud_storage.go │ │ └── usecase.go │ └── logic │ │ ├── filter.go │ │ ├── diff.go │ │ ├── filter_test.go │ │ └── diff_test.go ├── usecase │ ├── testdata │ │ ├── octovy-test-code-main.zip │ │ └── trivy-result.json │ ├── export_test.go │ ├── usecase.go │ ├── templates │ │ └── comment_body.md │ ├── insert_scan_result.go │ ├── comment_githug_pr.go │ ├── comment_githug_pr_test.go │ └── scan_github_repo.go ├── utils │ ├── hash.go │ ├── hash_test.go │ ├── error.go │ ├── safe.go │ ├── test.go │ ├── context.go │ └── logger.go └── controller │ ├── cli │ ├── config │ │ ├── policy.go │ │ ├── sentry.go │ │ ├── cs.go │ │ ├── github_app.go │ │ └── bq.go │ ├── cli.go │ ├── insert.go │ └── serve │ │ └── serve.go │ └── server │ ├── middleware.go │ ├── server.go │ ├── github.go │ ├── github_test.go │ └── testdata │ └── github │ ├── push.default.json │ └── push.json ├── main.go ├── .gitignore ├── Makefile ├── Dockerfile ├── .github └── workflows │ ├── trivy.yml │ ├── gosec.yml │ ├── test.yml │ └── publish.yml ├── README.md ├── go.mod ├── CLAUDE.md └── LICENSE /pkg/infra/gh/comments.json: -------------------------------------------------------------------------------- 1 | null 2 | -------------------------------------------------------------------------------- /pkg/domain/model/bigquery.go: -------------------------------------------------------------------------------- 1 | package model 2 | -------------------------------------------------------------------------------- /pkg/domain/model/github_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | -------------------------------------------------------------------------------- /pkg/domain/types/const.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | const ( 4 | GitHubCommentSignature = "" 5 | ) 6 | -------------------------------------------------------------------------------- /pkg/usecase/testdata/octovy-test-code-main.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/secmon-lab/octovy/HEAD/pkg/usecase/testdata/octovy-test-code-main.zip -------------------------------------------------------------------------------- /pkg/domain/types/github.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type ( 4 | GitHubAppID int64 5 | GitHubAppInstallID int64 6 | GitHubAppSecret string 7 | GitHubAppPrivateKey string 8 | ) 9 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/m-mizutani/octovy/pkg/controller/cli" 7 | ) 8 | 9 | func main() { 10 | if err := cli.New().Run(os.Args); err != nil { 11 | os.Exit(1) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pkg/infra/gh/queries/minimize_comment.graphql: -------------------------------------------------------------------------------- 1 | mutation ($id: ID!) { 2 | minimizeComment(input: { subjectId: $id, classifier: OUTDATED }) { 3 | minimizedComment { 4 | isMinimized 5 | minimizedReason 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !assets/next.config.js 3 | !jest.config.js 4 | *.d.ts 5 | node_modules 6 | assets/out 7 | assets/.next 8 | /*.json 9 | 10 | tmp 11 | /pkg/usecase/templates/test_* 12 | 13 | trivy.db 14 | octovy 15 | .env* 16 | .vscode 17 | .cckiro 18 | -------------------------------------------------------------------------------- /pkg/utils/hash.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | ) 7 | 8 | func HashBranch(branch string) string { 9 | h := sha256.New() 10 | h.Write([]byte(branch)) 11 | v := hex.EncodeToString(h.Sum(nil)) 12 | return v 13 | } 14 | -------------------------------------------------------------------------------- /pkg/domain/model/schema/ignore.cue: -------------------------------------------------------------------------------- 1 | package octovy 2 | 3 | import "time" 4 | 5 | #IgnoreConfig: { 6 | Target: string 7 | Vulns: [...#IgnoreVuln] @go(,[]IgnoreVuln) 8 | } 9 | 10 | #IgnoreVuln: { 11 | ID: string 12 | Comment?: string 13 | ExpiresAt: time.Time 14 | } 15 | 16 | IgnoreList?: [...#IgnoreConfig] 17 | -------------------------------------------------------------------------------- /pkg/utils/hash_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/m-mizutani/gt" 7 | "github.com/m-mizutani/octovy/pkg/utils" 8 | ) 9 | 10 | func TestHashBranch(t *testing.T) { 11 | v := utils.HashBranch("test") 12 | gt.Equal(t, v, "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/usecase/export_test.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/m-mizutani/octovy/pkg/domain/model" 7 | ) 8 | 9 | var RenderScanReport = renderScanReport 10 | 11 | func (x *UseCase) HideGitHubOldComments(ctx context.Context, input *model.ScanGitHubRepoInput) error { 12 | return x.hideGitHubOldComments(ctx, input) 13 | } 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: pkg/domain/mock/infra.go pkg/domain/mock/usecase.go 2 | 3 | cmd=go run github.com/matryer/moq@v0.3.4 4 | 5 | pkg/domain/mock/infra.go: ./pkg/domain/interfaces/infra.go 6 | $(cmd) -out pkg/domain/mock/infra.go -pkg mock ./pkg/domain/interfaces GitHub BigQuery 7 | 8 | pkg/domain/mock/usecase.go: ./pkg/domain/interfaces/usecase.go 9 | $(cmd) -out pkg/domain/mock/usecase.go -pkg mock ./pkg/domain/interfaces UseCase 10 | -------------------------------------------------------------------------------- /pkg/domain/interfaces/usecase.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/m-mizutani/octovy/pkg/domain/model" 7 | "github.com/m-mizutani/octovy/pkg/domain/model/trivy" 8 | ) 9 | 10 | type UseCase interface { 11 | InsertScanResult(ctx context.Context, meta model.GitHubMetadata, report trivy.Report, cfg model.Config) error 12 | ScanGitHubRepo(ctx context.Context, input *model.ScanGitHubRepoInput) error 13 | } 14 | -------------------------------------------------------------------------------- /pkg/domain/model/trivy/secret_finding.go: -------------------------------------------------------------------------------- 1 | package trivy 2 | 3 | type SecretRuleCategory string 4 | 5 | type Secret struct { 6 | FilePath string 7 | Findings []SecretFinding 8 | } 9 | 10 | type SecretFinding struct { 11 | RuleID string 12 | Category SecretRuleCategory 13 | Severity string 14 | Title string 15 | StartLine int 16 | EndLine int 17 | Code Code 18 | Match string 19 | Layer Layer `json:",omitempty"` 20 | } 21 | -------------------------------------------------------------------------------- /pkg/domain/model/usecase.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/m-mizutani/goerr/v2" 5 | "github.com/m-mizutani/octovy/pkg/domain/types" 6 | ) 7 | 8 | type ScanGitHubRepoInput struct { 9 | GitHubMetadata 10 | InstallID types.GitHubAppInstallID 11 | } 12 | 13 | func (x *ScanGitHubRepoInput) Validate() error { 14 | if err := x.GitHubMetadata.Validate(); err != nil { 15 | return err 16 | } 17 | if x.InstallID == 0 { 18 | return goerr.Wrap(types.ErrInvalidOption, "install ID is empty") 19 | } 20 | 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /pkg/infra/gh/queries/list_comments.graphql: -------------------------------------------------------------------------------- 1 | query ($owner: String!, $name: String!, $issueNumber: Int!, $cursor: String) { 2 | repository(owner: $owner, name: $name) { 3 | pullRequest(number: $issueNumber) { 4 | title 5 | comments(first: 100, after: $cursor) { 6 | edges { 7 | cursor 8 | node { 9 | id 10 | author { 11 | login 12 | } 13 | body 14 | isMinimized 15 | } 16 | } 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pkg/domain/model/testdata/config/ignore.cue: -------------------------------------------------------------------------------- 1 | package octovy 2 | 3 | IgnoreList: [ 4 | { 5 | Target: "test.data" 6 | Vulns: [ 7 | { 8 | ID: "CVE-2017-9999" 9 | Comment: "This is test data" 10 | ExpiresAt: "2018-01-01T00:00:00Z" 11 | }, 12 | ] 13 | }, 14 | { 15 | Target: "test2.data" 16 | Vulns: [ 17 | { 18 | ID: "CVE-2017-11423" 19 | Comment: "Hoge" 20 | ExpiresAt: "2022-03-04T00:00:00Z" 21 | }, 22 | { 23 | ID: "CVE-2023-11423" 24 | Comment: "Hoge" 25 | ExpiresAt: "2023-03-04T00:00:00Z" 26 | }, 27 | ] 28 | }, 29 | ] 30 | -------------------------------------------------------------------------------- /pkg/utils/error.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/getsentry/sentry-go" 8 | "github.com/m-mizutani/goerr/v2" 9 | ) 10 | 11 | func HandleError(ctx context.Context, msg string, err error) { 12 | // Sending error to Sentry 13 | hub := sentry.CurrentHub().Clone() 14 | hub.ConfigureScope(func(scope *sentry.Scope) { 15 | if goErr := goerr.Unwrap(err); goErr != nil { 16 | for k, v := range goErr.Values() { 17 | scope.SetExtra(fmt.Sprintf("%v", k), v) 18 | } 19 | } 20 | }) 21 | evID := hub.CaptureException(err) 22 | 23 | CtxLogger(ctx).Error(msg, 24 | "error", err, 25 | "sentry.EventID", evID, 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/domain/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "github.com/google/uuid" 4 | 5 | type ( 6 | ScanID string 7 | 8 | RequestID string 9 | ) 10 | 11 | func NewScanID() ScanID { return ScanID(uuid.NewString()) } 12 | func (x ScanID) String() string { return string(x) } 13 | 14 | func NewRequestID() RequestID { return RequestID(uuid.NewString()) } 15 | func (x RequestID) String() string { return string(x) } 16 | 17 | type ( 18 | GoogleProjectID string 19 | 20 | BQDatasetID string 21 | BQTableID string 22 | ) 23 | 24 | func (x GoogleProjectID) String() string { return string(x) } 25 | func (x BQDatasetID) String() string { return string(x) } 26 | func (x BQTableID) String() string { return string(x) } 27 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24.3 AS build-go 2 | ENV CGO_ENABLED=0 3 | ARG BUILD_VERSION 4 | 5 | WORKDIR /app 6 | RUN go env -w GOMODCACHE=/root/.cache/go-build 7 | 8 | COPY go.mod go.sum ./ 9 | RUN --mount=type=cache,target=/root/.cache/go-build go mod download 10 | 11 | COPY . /app 12 | RUN --mount=type=cache,target=/root/.cache/go-build go build -o octovy -ldflags "-X github.com/m-mizutani/octovy/pkg/domain/types.AppVersion=${BUILD_VERSION}" . 13 | 14 | FROM gcr.io/distroless/base:nonroot 15 | USER nonroot 16 | COPY --from=build-go /app/octovy /octovy 17 | COPY --from=aquasec/trivy:0.50.4 /usr/local/bin/trivy /trivy 18 | WORKDIR / 19 | ENV OCTOVY_ADDR="0.0.0.0:8000" 20 | ENV OCTOVY_TRIVY_PATH=/trivy 21 | EXPOSE 8000 22 | 23 | ENTRYPOINT ["/octovy"] 24 | 25 | -------------------------------------------------------------------------------- /pkg/domain/model/result.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/m-mizutani/octovy/pkg/domain/model/trivy" 7 | "github.com/m-mizutani/octovy/pkg/domain/types" 8 | ) 9 | 10 | type Scan struct { 11 | ID types.ScanID `bigquery:"id" firestore:"id" json:"id"` 12 | Timestamp time.Time `bigquery:"timestamp" firestore:"timestamp" json:"timestamp"` 13 | GitHub GitHubMetadata `bigquery:"github" firestore:"github" json:"github"` 14 | Report trivy.Report `bigquery:"report" firestore:"report" json:"report"` 15 | Config string `bigquery:"config" firestore:"config" json:"config"` 16 | } 17 | 18 | type ScanRawRecord struct { 19 | Scan 20 | Timestamp int64 `bigquery:"timestamp" firestore:"timestamp" json:"timestamp"` 21 | } 22 | -------------------------------------------------------------------------------- /pkg/usecase/usecase.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "github.com/m-mizutani/octovy/pkg/domain/types" 5 | "github.com/m-mizutani/octovy/pkg/infra" 6 | ) 7 | 8 | type UseCase struct { 9 | tableID types.BQTableID 10 | clients *infra.Clients 11 | 12 | disableNoDetectionComment bool 13 | } 14 | 15 | func New(clients *infra.Clients, options ...Option) *UseCase { 16 | uc := &UseCase{ 17 | tableID: "scans", 18 | clients: clients, 19 | } 20 | 21 | for _, opt := range options { 22 | opt(uc) 23 | } 24 | 25 | return uc 26 | } 27 | 28 | type Option func(*UseCase) 29 | 30 | func WithBigQueryTableID(tableID types.BQTableID) Option { 31 | return func(x *UseCase) { 32 | x.tableID = tableID 33 | } 34 | } 35 | 36 | func WithDisableNoDetectionComment() Option { 37 | return func(x *UseCase) { 38 | x.disableNoDetectionComment = true 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/trivy.yml: -------------------------------------------------------------------------------- 1 | name: trivy 2 | 3 | on: [push] 4 | 5 | jobs: 6 | scan: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout upstream repo 11 | uses: actions/checkout@v2 12 | with: 13 | ref: ${{ github.head_ref }} 14 | - name: Run Trivy vulnerability scanner in repo mode 15 | uses: aquasecurity/trivy-action@master 16 | with: 17 | scan-type: "fs" 18 | scan-ref: "." 19 | ignore-unfixed: true 20 | format: "template" 21 | template: "@/contrib/sarif.tpl" 22 | output: "trivy-results.sarif" 23 | skip-dirs: pkg/infra/trivy/testdata 24 | 25 | - name: Upload Trivy scan results to GitHub Security tab 26 | uses: github/codeql-action/upload-sarif@v1 27 | with: 28 | sarif_file: "trivy-results.sarif" 29 | -------------------------------------------------------------------------------- /pkg/domain/types/error.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "errors" 4 | 5 | var ( 6 | // ErrInvalidOption is an error that indicates an invalid option is given by user via CLI or configuration 7 | ErrInvalidOption = errors.New("invalid option") 8 | 9 | // ErrInvalidRequest is an error that indicates an invalid HTTP request 10 | ErrInvalidRequest = errors.New("invalid request") 11 | 12 | // ErrInvalidResponse is an error that indicates a failure in data consistency in the application 13 | ErrValidationFailed = errors.New("validation failed") 14 | 15 | // ErrInvalidGitHubData is an error that indicates an invalid data provided by GitHub. Mainly used in GitHub API response 16 | ErrInvalidGitHubData = errors.New("invalid GitHub data") 17 | 18 | // ErrLogicError is an error that indicates a logic error in the application 19 | ErrLogicError = errors.New("logic error") 20 | ) 21 | -------------------------------------------------------------------------------- /.github/workflows/gosec.yml: -------------------------------------------------------------------------------- 1 | name: "gosec" 2 | 3 | # Run workflow each time code is pushed to your repository and on a schedule. 4 | # The scheduled workflow runs every at 00:00 on Sunday UTC time. 5 | on: 6 | push: 7 | 8 | jobs: 9 | tests: 10 | runs-on: ubuntu-latest 11 | env: 12 | GO111MODULE: on 13 | steps: 14 | - name: Checkout Source 15 | uses: actions/checkout@v2 16 | - name: Run Gosec Security Scanner 17 | uses: securego/gosec@master 18 | with: 19 | # we let the report trigger content trigger a failure using the GitHub Security features. 20 | args: "-no-fail -fmt sarif -out results.sarif ./..." 21 | - name: Upload SARIF file 22 | uses: github/codeql-action/upload-sarif@v1 23 | with: 24 | # Path to SARIF file relative to the root of the repository 25 | sarif_file: results.sarif 26 | -------------------------------------------------------------------------------- /pkg/controller/cli/config/policy.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/m-mizutani/goerr/v2" 5 | "github.com/m-mizutani/opac" 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | type Policy struct { 10 | files cli.StringSlice 11 | } 12 | 13 | func (x *Policy) Flags() []cli.Flag { 14 | return []cli.Flag{ 15 | &cli.StringSliceFlag{ 16 | Name: "policy-file", 17 | Usage: "Policy files to evaluate", 18 | EnvVars: []string{"OCTOVY_POLICY_FILE"}, 19 | Destination: &x.files, 20 | }, 21 | } 22 | } 23 | 24 | func (x *Policy) Configure() (*opac.Client, error) { 25 | if len(x.files.Value()) == 0 { 26 | return nil, nil 27 | } 28 | 29 | client, err := opac.New(opac.Files(x.files.Value()...)) 30 | if err != nil { 31 | return nil, goerr.Wrap(err, "Failed to initialize policy engine", goerr.V("files", x.files.Value())) 32 | } 33 | 34 | return client, nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/utils/safe.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "database/sql" 5 | "io" 6 | "os" 7 | 8 | "log/slog" 9 | ) 10 | 11 | func SafeClose(closer io.Closer) { 12 | if closer != nil { 13 | if err := closer.Close(); err != nil { 14 | if err == io.EOF { 15 | return 16 | } 17 | logger.Warn("Fail to close resource", slog.Any("error", err)) 18 | } 19 | } 20 | } 21 | 22 | func SafeRemove(path string) { 23 | if err := os.Remove(path); err != nil { 24 | logger.Warn("Fail to remove file", slog.Any("error", err)) 25 | } 26 | } 27 | 28 | func SafeRemoveAll(path string) { 29 | if err := os.RemoveAll(path); err != nil { 30 | logger.Warn("Fail to remove file", slog.Any("error", err)) 31 | } 32 | } 33 | 34 | func SafeRollback(tx *sql.Tx) { 35 | if err := tx.Rollback(); err != nil && err != sql.ErrTxDone { 36 | logger.Warn("Fail to rollback transaction", slog.Any("error", err)) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | testing: 7 | runs-on: ubuntu-latest 8 | services: 9 | postgres: 10 | image: postgres:14 11 | env: 12 | POSTGRES_USER: pguser 13 | POSTGRES_PASSWORD: pgpass 14 | POSTGRES_DB: testdb 15 | ports: 16 | - 5432:5432 17 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 18 | 19 | steps: 20 | - name: Checkout upstream repo 21 | uses: actions/checkout@v2 22 | with: 23 | ref: ${{ github.head_ref }} 24 | - uses: actions/setup-go@v4 25 | with: 26 | go-version-file: "go.mod" 27 | - run: go test --tags github ./... 28 | env: 29 | TEST_DB_DSN: "user=pguser password=pgpass dbname=testdb sslmode=disable" 30 | - run: go vet --tags github ./... 31 | -------------------------------------------------------------------------------- /pkg/domain/model/trivy/detected_license.go: -------------------------------------------------------------------------------- 1 | package trivy 2 | 3 | type DetectedLicense struct { 4 | // Severity is the consistent parameter indicating how severe the issue is 5 | Severity string 6 | 7 | // Category holds the license category such as "forbidden" 8 | Category LicenseCategory 9 | 10 | // PkgName holds a package name of the license. 11 | // It will be empty if FilePath is filled. 12 | PkgName string 13 | 14 | // PkgName holds a file path of the license. 15 | // It will be empty if PkgName is filled. 16 | FilePath string // for file license 17 | 18 | // Name holds a detected license name 19 | Name string 20 | 21 | // Confidence is level of the match. The confidence level is between 0.0 and 1.0, with 1.0 indicating an 22 | // exact match and 0.0 indicating a complete mismatch 23 | Confidence float64 24 | 25 | // Link is a SPDX link of the license 26 | Link string 27 | } 28 | 29 | type LicenseCategory string 30 | -------------------------------------------------------------------------------- /pkg/infra/cs/client_test.go: -------------------------------------------------------------------------------- 1 | package cs_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/google/uuid" 10 | "github.com/m-mizutani/gt" 11 | "github.com/m-mizutani/octovy/pkg/infra/cs" 12 | "github.com/m-mizutani/octovy/pkg/utils" 13 | ) 14 | 15 | func TestCloudStorage(t *testing.T) { 16 | bucket := utils.LoadEnv(t, "TEST_CLOUD_STORAGE_BUCKET") 17 | 18 | t.Run("Put and Get", func(t *testing.T) { 19 | client, err := cs.New(context.Background(), bucket) 20 | gt.NoError(t, err) 21 | 22 | key := "test-key/" + uuid.NewString() + ".txt" 23 | r := strings.NewReader("blue") 24 | 25 | gt.NoError(t, client.Put(context.Background(), key, io.NopCloser(r))) 26 | 27 | r2, err := client.Get(context.Background(), key) 28 | gt.NoError(t, err) 29 | defer r2.Close() 30 | 31 | data, err := io.ReadAll(r2) 32 | gt.NoError(t, err) 33 | gt.Equal(t, "blue", string(data)) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/infra/trivy/client.go: -------------------------------------------------------------------------------- 1 | package trivy 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "os/exec" 7 | 8 | "github.com/m-mizutani/goerr/v2" 9 | "github.com/m-mizutani/octovy/pkg/utils" 10 | ) 11 | 12 | type Client interface { 13 | Run(ctx context.Context, args []string) error 14 | } 15 | 16 | type clientImpl struct { 17 | path string 18 | } 19 | 20 | func New(path string) Client { 21 | return &clientImpl{ 22 | path: path, 23 | } 24 | } 25 | 26 | func (x *clientImpl) Run(ctx context.Context, args []string) error { 27 | // Why: The arguments are not from user input 28 | // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command 29 | // #nosec: G204 30 | cmd := exec.CommandContext(ctx, x.path, args...) 31 | var stdout bytes.Buffer 32 | var stderr bytes.Buffer 33 | cmd.Stdout = &stdout 34 | cmd.Stderr = &stderr 35 | 36 | if err := cmd.Run(); err != nil { 37 | utils.CtxLogger(ctx).With("stderr", stderr.String()).With("stdout", stdout.String()).Error("trivy failed") 38 | return goerr.Wrap(err, "executing trivy", goerr.V("stderr", stderr.String()), goerr.V("stdout", stdout.String())) 39 | } 40 | 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /pkg/domain/model/config_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | _ "embed" 5 | "testing" 6 | 7 | "github.com/m-mizutani/gt" 8 | "github.com/m-mizutani/octovy/pkg/domain/model" 9 | ) 10 | 11 | //go:embed testdata/config/ignore.cue 12 | var testConfigIgnoreCue []byte 13 | 14 | func TestIgnoreConfig(t *testing.T) { 15 | cfg, err := model.BuildConfig(testConfigIgnoreCue) 16 | gt.NoError(t, err) 17 | gt.A(t, cfg.IgnoreList).Length(2). 18 | At(0, func(t testing.TB, v model.IgnoreConfig) { 19 | gt.Equal(t, v.Target, "test.data") 20 | gt.A(t, v.Vulns).Length(1).At(0, func(t testing.TB, v model.IgnoreVuln) { 21 | gt.Equal(t, v.ID, "CVE-2017-9999") 22 | gt.Equal(t, v.Comment, "This is test data") 23 | gt.Equal(t, v.ExpiresAt.Year(), 2018) 24 | }) 25 | }). 26 | At(1, func(t testing.TB, v model.IgnoreConfig) { 27 | gt.Equal(t, v.Target, "test2.data") 28 | gt.A(t, v.Vulns).Length(2). 29 | At(0, func(t testing.TB, v model.IgnoreVuln) { 30 | gt.Equal(t, v.ID, "CVE-2017-11423") 31 | gt.Equal(t, v.ExpiresAt.Year(), 2022) 32 | }). 33 | At(1, func(t testing.TB, v model.IgnoreVuln) { 34 | gt.Equal(t, v.ID, "CVE-2023-11423") 35 | gt.Equal(t, v.ExpiresAt.Year(), 2023) 36 | }) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/controller/server/middleware.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "log/slog" 8 | 9 | "github.com/google/uuid" 10 | "github.com/m-mizutani/octovy/pkg/utils" 11 | ) 12 | 13 | func preProcess(next http.Handler) http.Handler { 14 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | logger := utils.Logger().With(slog.String("request_id", uuid.NewString())) 16 | 17 | ctx := utils.CtxWithLogger(r.Context(), logger) 18 | 19 | lw := &statusCodeLogger{ResponseWriter: w} 20 | 21 | requestedAt := time.Now() 22 | next.ServeHTTP(lw, r.WithContext(ctx)) 23 | 24 | logger.Info("http access", 25 | slog.String("method", r.Method), 26 | slog.String("path", r.URL.Path), 27 | slog.String("remote_addr", r.RemoteAddr), 28 | slog.Int("status_code", lw.statusCode), 29 | slog.Int64("content_length", r.ContentLength), 30 | slog.String("user_agent", r.UserAgent()), 31 | slog.String("referer", r.Referer()), 32 | slog.Duration("elapsed", time.Since(requestedAt)), 33 | ) 34 | }) 35 | } 36 | 37 | type statusCodeLogger struct { 38 | http.ResponseWriter 39 | statusCode int 40 | } 41 | 42 | func (x *statusCodeLogger) WriteHeader(code int) { 43 | x.statusCode = code 44 | x.ResponseWriter.WriteHeader(code) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/domain/mock/cloud_storage.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | 9 | "github.com/m-mizutani/goerr/v2" 10 | "github.com/m-mizutani/octovy/pkg/domain/interfaces" 11 | ) 12 | 13 | type StorageMock struct { 14 | Data map[string][]byte 15 | } 16 | 17 | var _ interfaces.Storage = (*StorageMock)(nil) 18 | 19 | func NewStorageMock() *StorageMock { 20 | return &StorageMock{ 21 | Data: make(map[string][]byte), 22 | } 23 | } 24 | 25 | // Get implements Storage. 26 | func (s *StorageMock) Get(ctx context.Context, key string) (io.ReadCloser, error) { 27 | if data, ok := s.Data[key]; ok { 28 | return io.NopCloser(bytes.NewReader(data)), nil 29 | } 30 | return nil, nil 31 | } 32 | 33 | // Put implements Storage. 34 | func (s *StorageMock) Put(ctx context.Context, key string, r io.ReadCloser) error { 35 | data, err := io.ReadAll(r) 36 | if err != nil { 37 | return err 38 | } 39 | s.Data[key] = data 40 | return nil 41 | } 42 | 43 | func (s *StorageMock) Unmarshal(key string, v interface{}) error { 44 | data, ok := s.Data[key] 45 | if !ok { 46 | return io.EOF 47 | } 48 | 49 | if err := json.Unmarshal(data, v); err != nil { 50 | return goerr.Wrap(err, "Failed to unmarshal data") 51 | } 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /pkg/infra/trivy/client_test.go: -------------------------------------------------------------------------------- 1 | package trivy_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/m-mizutani/gt" 11 | "github.com/m-mizutani/octovy/pkg/infra/trivy" 12 | 13 | trivy_model "github.com/m-mizutani/octovy/pkg/domain/model/trivy" 14 | ) 15 | 16 | func Test(t *testing.T) { 17 | path, ok := os.LookupEnv("TEST_TRIVY_PATH") 18 | if !ok { 19 | t.Skip("TEST_TRIVY_PATH is not set") 20 | } 21 | 22 | target := gt.R1(filepath.Abs("../../../")).NoError(t) 23 | t.Log(target) 24 | 25 | tmp := gt.R1(os.CreateTemp("", "trivy-scan-*.json")).NoError(t) 26 | gt.NoError(t, tmp.Close()) 27 | 28 | client := trivy.New(path) 29 | ctx := context.Background() 30 | gt.NoError(t, client.Run(ctx, []string{ 31 | "fs", 32 | target, 33 | "-f", "json", 34 | "-o", tmp.Name(), 35 | "--list-all-pkgs", 36 | })) 37 | 38 | var report trivy_model.Report 39 | body := gt.R1(os.ReadFile(tmp.Name())).NoError(t) 40 | gt.NoError(t, json.Unmarshal(body, &report)) 41 | gt.V(t, report.SchemaVersion).Equal(2) 42 | gt.A(t, report.Results).Longer(0).Any(func(v trivy_model.Result) bool { 43 | if v.Target == "go.mod" { 44 | gt.A(t, v.Packages).Any(func(v trivy_model.Package) bool { 45 | return v.Name == "github.com/m-mizutani/goerr/v2" 46 | }) 47 | } 48 | 49 | return v.Target == "go.mod" 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /pkg/controller/cli/config/sentry.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "github.com/getsentry/sentry-go" 7 | "github.com/m-mizutani/goerr/v2" 8 | "github.com/m-mizutani/octovy/pkg/utils" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | type Sentry struct { 13 | dsn string 14 | env string 15 | } 16 | 17 | func (x *Sentry) Flags() []cli.Flag { 18 | return []cli.Flag{ 19 | &cli.StringFlag{ 20 | Name: "sentry-dsn", 21 | Usage: "Sentry DSN for error reporting", 22 | EnvVars: []string{"OCTOVY_SENTRY_DSN"}, 23 | Destination: &x.dsn, 24 | }, 25 | &cli.StringFlag{ 26 | Name: "sentry-env", 27 | Usage: "Sentry environment", 28 | EnvVars: []string{"OCTOVY_SENTRY_ENV"}, 29 | Destination: &x.env, 30 | }, 31 | } 32 | } 33 | 34 | func (x *Sentry) Configure() error { 35 | if x.dsn != "" { 36 | utils.Logger().Info("Enable Sentry", "DSN", x.dsn, "env", x.env) 37 | if err := sentry.Init(sentry.ClientOptions{ 38 | Dsn: x.dsn, 39 | Environment: x.env, 40 | }); err != nil { 41 | return goerr.Wrap(err, "failed to initialize Sentry") 42 | } 43 | } else { 44 | utils.Logger().Warn("sentry is not enabled") 45 | } 46 | 47 | return nil 48 | } 49 | 50 | func (x *Sentry) LogValue() slog.Value { 51 | return slog.GroupValue( 52 | slog.String("dsn", x.dsn), 53 | slog.String("env", x.env), 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /pkg/controller/cli/config/cs.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "github.com/m-mizutani/octovy/pkg/domain/interfaces" 8 | "github.com/m-mizutani/octovy/pkg/infra/cs" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | type CloudStorage struct { 13 | bucket string 14 | prefix string 15 | } 16 | 17 | func (x *CloudStorage) Flags() []cli.Flag { 18 | return []cli.Flag{ 19 | &cli.StringFlag{ 20 | Name: "cloud-storage-bucket", 21 | Usage: "Cloud Storage bucket name", 22 | Category: "Cloud Storage", 23 | Destination: &x.bucket, 24 | EnvVars: []string{"OCTOVY_CLOUD_STORAGE_BUCKET"}, 25 | }, 26 | &cli.StringFlag{ 27 | Name: "cloud-storage-prefix", 28 | Usage: "Cloud Storage prefix", 29 | Category: "Cloud Storage", 30 | Destination: &x.prefix, 31 | EnvVars: []string{"OCTOVY_CLOUD_STORAGE_PREFIX"}, 32 | }, 33 | } 34 | } 35 | 36 | func (x *CloudStorage) LogValue() slog.Value { 37 | return slog.GroupValue( 38 | slog.Any("Bucket", x.bucket), 39 | slog.Any("Prefix", x.prefix), 40 | ) 41 | } 42 | 43 | func (x *CloudStorage) NewClient(ctx context.Context) (interfaces.Storage, error) { 44 | if x.bucket == "" { 45 | return nil, nil 46 | } 47 | 48 | var options []cs.Option 49 | if x.prefix != "" { 50 | options = append(options, cs.WithPrefix(x.prefix)) 51 | } 52 | 53 | return cs.New(ctx, x.bucket, options...) 54 | } 55 | -------------------------------------------------------------------------------- /pkg/utils/test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | // LoadEnv loads environment variable and return its value for test code. If the variable is not set, it skips the test. 11 | func LoadEnv(t *testing.T, name string) string { 12 | t.Helper() 13 | v, ok := os.LookupEnv(name) 14 | if !ok { 15 | t.Skipf("Skip test because %s is not set", name) 16 | } 17 | 18 | return v 19 | } 20 | 21 | // LoadJson loads JSON file and decode it into v for test code. If it fails, it fails the test. 22 | func LoadJson(t *testing.T, path string, v interface{}) { 23 | t.Helper() 24 | fp, err := os.Open(filepath.Clean(path)) 25 | if err != nil { 26 | t.Fatalf("Failed to open file: %s", path) 27 | } 28 | defer SafeClose(fp) 29 | 30 | if err := json.NewDecoder(fp).Decode(v); err != nil { 31 | t.Fatalf("Failed to decode JSON: %s", err) 32 | } 33 | } 34 | 35 | // DumpJson dumps v into JSON file for test code. If it fails, it fails the test. 36 | func DumpJson(t *testing.T, path string, v interface{}) { 37 | if t != nil { 38 | t.Helper() 39 | } 40 | fp, err := os.Create(filepath.Clean(path)) 41 | if err != nil { 42 | if t != nil { 43 | t.Fatalf("Failed to create file: %s", path) 44 | } 45 | panic(err) 46 | } 47 | defer SafeClose(fp) 48 | 49 | if err := json.NewEncoder(fp).Encode(v); err != nil { 50 | if t != nil { 51 | t.Fatalf("Failed to encode JSON: %s", err) 52 | } 53 | panic(err) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pkg/domain/logic/filter.go: -------------------------------------------------------------------------------- 1 | package logic 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/m-mizutani/octovy/pkg/domain/model" 7 | "github.com/m-mizutani/octovy/pkg/domain/model/trivy" 8 | ) 9 | 10 | func FilterReport(oldReport *trivy.Report, cfg *model.Config, now time.Time) *trivy.Report { 11 | results := FilterResults(oldReport.Results, cfg, now) 12 | newReport := *oldReport 13 | newReport.Results = results 14 | return &newReport 15 | } 16 | 17 | func FilterResults(results trivy.Results, cfg *model.Config, now time.Time) trivy.Results { 18 | ignoreMap := make(map[string]map[string]struct{}) 19 | for _, target := range cfg.IgnoreList { 20 | if _, ok := ignoreMap[target.Target]; !ok { 21 | ignoreMap[target.Target] = make(map[string]struct{}) 22 | } 23 | 24 | for _, vuln := range target.Vulns { 25 | if vuln.IsActive(now) { 26 | continue 27 | } 28 | ignoreMap[target.Target][vuln.ID] = struct{}{} 29 | } 30 | } 31 | 32 | var filtered trivy.Results 33 | for _, result := range results { 34 | newResult := result 35 | ignoreVulns, ok := ignoreMap[result.Target] 36 | if !ok { 37 | filtered = append(filtered, newResult) 38 | continue 39 | } 40 | newResult.Vulnerabilities = nil 41 | 42 | for _, vuln := range result.Vulnerabilities { 43 | if _, ok := ignoreVulns[vuln.VulnerabilityID]; ok { 44 | continue 45 | } 46 | 47 | newResult.Vulnerabilities = append(newResult.Vulnerabilities, vuln) 48 | } 49 | 50 | if len(newResult.Vulnerabilities) > 0 { 51 | filtered = append(filtered, newResult) 52 | } 53 | } 54 | return filtered 55 | } 56 | -------------------------------------------------------------------------------- /pkg/utils/context.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "time" 7 | 8 | "github.com/m-mizutani/octovy/pkg/domain/types" 9 | ) 10 | 11 | type ctxRequestIDKey struct{} 12 | 13 | // CtxRequestID returns request ID from context. If request ID is not set, return new request ID and context with it 14 | func CtxRequestID(ctx context.Context) (types.RequestID, context.Context) { 15 | if id, ok := ctx.Value(ctxRequestIDKey{}).(types.RequestID); ok { 16 | return id, ctx 17 | } 18 | 19 | newID := types.NewRequestID() 20 | return newID, context.WithValue(ctx, ctxRequestIDKey{}, newID) 21 | } 22 | 23 | type ctxLoggerKey struct{} 24 | 25 | // WithLogger returns a new context with logger 26 | func CtxWithLogger(ctx context.Context, logger *slog.Logger) context.Context { 27 | return context.WithValue(ctx, ctxLoggerKey{}, logger) 28 | } 29 | 30 | // CtxLogger returns logger from context. If logger is not set, return default logger 31 | func CtxLogger(ctx context.Context) *slog.Logger { 32 | if l, ok := ctx.Value(ctxLoggerKey{}).(*slog.Logger); ok { 33 | return l 34 | } 35 | return logger 36 | } 37 | 38 | type ctxTimeKey struct{} 39 | type TimeFunc func() time.Time 40 | 41 | // CtxTime returns time from context. If time is not set, return current time and context with it 42 | func CtxTime(ctx context.Context) time.Time { 43 | if t, ok := ctx.Value(ctxTimeKey{}).(TimeFunc); ok { 44 | return t() 45 | } 46 | return time.Now() 47 | } 48 | 49 | // CtxWithTime returns a new context with time function 50 | func CtxWithTime(ctx context.Context, timeFunc TimeFunc) context.Context { 51 | return context.WithValue(ctx, ctxTimeKey{}, timeFunc) 52 | } 53 | -------------------------------------------------------------------------------- /pkg/domain/logic/diff.go: -------------------------------------------------------------------------------- 1 | package logic 2 | 3 | import "github.com/m-mizutani/octovy/pkg/domain/model/trivy" 4 | 5 | func DiffResults(oldReport, newReport *trivy.Report) (fixed, added trivy.Results) { 6 | resultMap := map[string]trivy.Result{} 7 | for _, result := range oldReport.Results { 8 | resultMap[result.Target] = result 9 | } 10 | 11 | for _, newResult := range newReport.Results { 12 | oldResult, ok := resultMap[newResult.Target] 13 | if !ok { 14 | added = append(added, newResult) 15 | continue 16 | } 17 | 18 | fixedVuln, addedVuln := DiffVulnerabilities(oldResult.Vulnerabilities, newResult.Vulnerabilities) 19 | if len(fixedVuln) > 0 { 20 | fixedResult := oldResult 21 | fixedResult.Vulnerabilities = fixedVuln 22 | fixed = append(fixed, fixedResult) 23 | } 24 | 25 | if len(addedVuln) > 0 { 26 | addedResult := newResult 27 | addedResult.Vulnerabilities = addedVuln 28 | added = append(added, addedResult) 29 | } 30 | 31 | delete(resultMap, newResult.Target) 32 | } 33 | 34 | for _, result := range resultMap { 35 | fixed = append(fixed, result) 36 | } 37 | 38 | return 39 | } 40 | 41 | func DiffVulnerabilities(oldVulns, newVulns []trivy.DetectedVulnerability) (fixed, added []trivy.DetectedVulnerability) { 42 | oldVulnMap := map[string]trivy.DetectedVulnerability{} 43 | for _, vuln := range oldVulns { 44 | 45 | oldVulnMap[vuln.ID()] = vuln 46 | } 47 | 48 | for _, newVuln := range newVulns { 49 | if _, ok := oldVulnMap[newVuln.ID()]; !ok { 50 | added = append(added, newVuln) 51 | } 52 | delete(oldVulnMap, newVuln.ID()) 53 | } 54 | 55 | for _, vuln := range oldVulnMap { 56 | fixed = append(fixed, vuln) 57 | } 58 | 59 | return 60 | } 61 | -------------------------------------------------------------------------------- /pkg/controller/cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/m-mizutani/octovy/pkg/controller/cli/serve" 5 | "github.com/m-mizutani/octovy/pkg/utils" 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | type CLI struct { 10 | } 11 | 12 | func New() *CLI { 13 | return &CLI{} 14 | } 15 | 16 | func (x *CLI) Run(argv []string) error { 17 | var ( 18 | logLevel string 19 | logFormat string 20 | logOutput string 21 | ) 22 | 23 | app := &cli.App{ 24 | Name: "octovy", 25 | Usage: "Vulnerability management system with Trivy", 26 | Flags: []cli.Flag{ 27 | &cli.StringFlag{ 28 | Name: "log-level", 29 | Usage: "Log level [trace|debug|info|warn|error]", 30 | Aliases: []string{"l"}, 31 | EnvVars: []string{"OCTOVY_LOG_LEVEL"}, 32 | Destination: &logLevel, 33 | Value: "info", 34 | }, 35 | &cli.StringFlag{ 36 | Name: "log-format", 37 | Usage: "Log format [text|json]", 38 | Aliases: []string{"f"}, 39 | EnvVars: []string{"OCTOVY_LOG_FORMAT"}, 40 | Destination: &logFormat, 41 | Value: "text", 42 | }, 43 | &cli.StringFlag{ 44 | Name: "log-output", 45 | Usage: "Log output [-|stdout|stderr|]", 46 | Aliases: []string{"o"}, 47 | EnvVars: []string{"OCTOVY_LOG_OUTPUT"}, 48 | Destination: &logOutput, 49 | Value: "-", 50 | }, 51 | }, 52 | Commands: []*cli.Command{ 53 | serve.New(), 54 | insertCommand(), 55 | }, 56 | Before: func(ctx *cli.Context) error { 57 | if err := utils.ReconfigureLogger(logFormat, logLevel, logOutput); err != nil { 58 | return err 59 | } 60 | return nil 61 | }, 62 | } 63 | 64 | if err := app.Run(argv); err != nil { 65 | utils.Logger().Error("fatal error", "error", err) 66 | return err 67 | } 68 | 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /pkg/infra/cs/client.go: -------------------------------------------------------------------------------- 1 | // Google Cloud Storage client 2 | package cs 3 | 4 | import ( 5 | "compress/gzip" 6 | "context" 7 | "io" 8 | 9 | "cloud.google.com/go/storage" 10 | "github.com/m-mizutani/goerr/v2" 11 | "github.com/m-mizutani/octovy/pkg/domain/interfaces" 12 | "github.com/m-mizutani/octovy/pkg/utils" 13 | ) 14 | 15 | type Client struct { 16 | bucket string 17 | prefix string 18 | client *storage.Client 19 | } 20 | 21 | var _ interfaces.Storage = (*Client)(nil) 22 | 23 | func New(ctx context.Context, bucket string, options ...Option) (*Client, error) { 24 | client, err := storage.NewClient(ctx) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return &Client{ 30 | bucket: bucket, 31 | client: client, 32 | }, nil 33 | } 34 | 35 | // Option is a functional option for New function 36 | type Option func(*Client) 37 | 38 | func WithPrefix(prefix string) Option { 39 | return func(c *Client) { 40 | c.prefix = prefix 41 | } 42 | } 43 | 44 | // Get implements interfaces.Storage. 45 | func (c *Client) Get(ctx context.Context, key string) (io.ReadCloser, error) { 46 | obj := c.client.Bucket(c.bucket).Object(c.prefix + key) 47 | r, err := obj.NewReader(ctx) 48 | if err != nil { 49 | // check if the object does not exist 50 | if err == storage.ErrObjectNotExist { 51 | return nil, nil 52 | } 53 | return nil, goerr.Wrap(err, "Failed to create object reader") 54 | } 55 | 56 | return r, nil 57 | } 58 | 59 | // Put implements interfaces.Storage. 60 | func (c *Client) Put(ctx context.Context, key string, r io.ReadCloser) error { 61 | obj := c.client.Bucket(c.bucket).Object(c.prefix + key) 62 | w := obj.NewWriter(ctx) 63 | w.ContentType = "application/json" 64 | w.ContentEncoding = "gzip" 65 | 66 | zw := gzip.NewWriter(w) 67 | 68 | defer func() { 69 | utils.SafeClose(zw) 70 | utils.SafeClose(w) 71 | }() 72 | 73 | if _, err := io.Copy(zw, r); err != nil { 74 | return goerr.Wrap(err, "Failed to write object") 75 | } 76 | 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /pkg/domain/interfaces/infra.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/url" 7 | 8 | "cloud.google.com/go/bigquery" 9 | 10 | "github.com/google/go-github/v53/github" 11 | "github.com/m-mizutani/octovy/pkg/domain/model" 12 | "github.com/m-mizutani/octovy/pkg/domain/types" 13 | "github.com/m-mizutani/opac" 14 | ) 15 | 16 | type BigQuery interface { 17 | Insert(ctx context.Context, tableID types.BQTableID, schema bigquery.Schema, data any) error 18 | 19 | GetMetadata(ctx context.Context, table types.BQTableID) (*bigquery.TableMetadata, error) 20 | UpdateTable(ctx context.Context, table types.BQTableID, md bigquery.TableMetadataToUpdate, eTag string) error 21 | CreateTable(ctx context.Context, table types.BQTableID, md *bigquery.TableMetadata) error 22 | } 23 | 24 | type Storage interface { 25 | Put(ctx context.Context, key string, r io.ReadCloser) error 26 | Get(ctx context.Context, key string) (io.ReadCloser, error) 27 | } 28 | 29 | type GitHub interface { 30 | GetArchiveURL(ctx context.Context, input *GetArchiveURLInput) (*url.URL, error) 31 | CreateIssueComment(ctx context.Context, repo *model.GitHubRepo, id types.GitHubAppInstallID, prID int, body string) error 32 | ListIssueComments(ctx context.Context, repo *model.GitHubRepo, id types.GitHubAppInstallID, prID int) ([]*model.GitHubIssueComment, error) 33 | MinimizeComment(ctx context.Context, repo *model.GitHubRepo, id types.GitHubAppInstallID, subjectID string) error 34 | CreateCheckRun(ctx context.Context, id types.GitHubAppInstallID, repo *model.GitHubRepo, commit string) (int64, error) 35 | UpdateCheckRun(ctx context.Context, id types.GitHubAppInstallID, repo *model.GitHubRepo, checkID int64, opt *github.UpdateCheckRunOptions) error 36 | } 37 | 38 | type GetArchiveURLInput struct { 39 | Owner string 40 | Repo string 41 | CommitID string 42 | InstallID types.GitHubAppInstallID 43 | } 44 | 45 | type Policy interface { 46 | Query(ctx context.Context, query string, input, output any, options ...opac.QueryOption) error 47 | } 48 | -------------------------------------------------------------------------------- /pkg/controller/cli/config/github_app.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/base64" 5 | "log/slog" 6 | 7 | "github.com/m-mizutani/octovy/pkg/domain/types" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | type GitHubApp struct { 12 | ID types.GitHubAppID 13 | Secret types.GitHubAppSecret 14 | privateKey types.GitHubAppPrivateKey 15 | EnableCheckRuns bool 16 | } 17 | 18 | func (x *GitHubApp) Flags() []cli.Flag { 19 | return []cli.Flag{ 20 | &cli.Int64Flag{ 21 | Name: "github-app-id", 22 | Usage: "GitHub App ID", 23 | Category: "GitHub App", 24 | Destination: (*int64)(&x.ID), 25 | EnvVars: []string{"OCTOVY_GITHUB_APP_ID"}, 26 | Required: true, 27 | }, 28 | &cli.StringFlag{ 29 | Name: "github-app-private-key", 30 | Usage: "GitHub App Private Key", 31 | Category: "GitHub App", 32 | Destination: (*string)(&x.privateKey), 33 | EnvVars: []string{"OCTOVY_GITHUB_APP_PRIVATE_KEY"}, 34 | Required: true, 35 | }, 36 | &cli.StringFlag{ 37 | Name: "github-app-secret", 38 | Usage: "GitHub App Webhook Secret", 39 | Category: "GitHub App", 40 | Destination: (*string)(&x.Secret), 41 | EnvVars: []string{"OCTOVY_GITHUB_APP_SECRET"}, 42 | }, 43 | &cli.BoolFlag{ 44 | Name: "github-app-enable-check-runs", 45 | Usage: "Enable GitHub Check Runs creation and updates", 46 | Category: "GitHub App", 47 | Destination: &x.EnableCheckRuns, 48 | EnvVars: []string{"OCTOVY_GITHUB_APP_ENABLE_CHECK_RUNS"}, 49 | }, 50 | } 51 | } 52 | 53 | func (x *GitHubApp) PrivateKey() types.GitHubAppPrivateKey { 54 | if raw, err := base64.StdEncoding.DecodeString(string(x.privateKey)); err == nil { 55 | return types.GitHubAppPrivateKey(raw) 56 | } 57 | return x.privateKey 58 | } 59 | 60 | func (x *GitHubApp) LogValue() slog.Value { 61 | return slog.GroupValue( 62 | slog.Any("ID", x.ID), 63 | slog.Any("Secret.len", len(x.Secret)), 64 | slog.Any("PrivateKey.len", len(x.privateKey)), 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /pkg/infra/clients.go: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/m-mizutani/octovy/pkg/domain/interfaces" 7 | "github.com/m-mizutani/octovy/pkg/infra/trivy" 8 | ) 9 | 10 | type Clients struct { 11 | githubApp interfaces.GitHub 12 | httpClient HTTPClient 13 | trivyClient trivy.Client 14 | bqClient interfaces.BigQuery 15 | storage interfaces.Storage 16 | policy interfaces.Policy 17 | } 18 | 19 | type HTTPClient interface { 20 | Do(req *http.Request) (*http.Response, error) 21 | } 22 | 23 | type Option func(*Clients) 24 | 25 | func New(options ...Option) *Clients { 26 | client := &Clients{ 27 | httpClient: http.DefaultClient, 28 | trivyClient: trivy.New("trivy"), 29 | } 30 | 31 | for _, opt := range options { 32 | opt(client) 33 | } 34 | 35 | return client 36 | } 37 | 38 | func (x *Clients) GitHubApp() interfaces.GitHub { 39 | return x.githubApp 40 | } 41 | func (x *Clients) HTTPClient() HTTPClient { 42 | return x.httpClient 43 | } 44 | func (x *Clients) Trivy() trivy.Client { 45 | return x.trivyClient 46 | } 47 | func (x *Clients) BigQuery() interfaces.BigQuery { 48 | return x.bqClient 49 | } 50 | func (x *Clients) Storage() interfaces.Storage { 51 | return x.storage 52 | } 53 | func (x *Clients) Policy() interfaces.Policy { 54 | return x.policy 55 | } 56 | 57 | func WithGitHubApp(client interfaces.GitHub) Option { 58 | return func(x *Clients) { 59 | x.githubApp = client 60 | } 61 | } 62 | 63 | func WithHTTPClient(client HTTPClient) Option { 64 | return func(x *Clients) { 65 | x.httpClient = client 66 | } 67 | } 68 | 69 | func WithTrivy(client trivy.Client) Option { 70 | return func(x *Clients) { 71 | x.trivyClient = client 72 | } 73 | } 74 | 75 | func WithBigQuery(client interfaces.BigQuery) Option { 76 | return func(x *Clients) { 77 | x.bqClient = client 78 | } 79 | } 80 | 81 | func WithStorage(client interfaces.Storage) Option { 82 | return func(x *Clients) { 83 | x.storage = client 84 | } 85 | } 86 | 87 | func WithPolicy(client interfaces.Policy) Option { 88 | return func(x *Clients) { 89 | x.policy = client 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /pkg/domain/model/trivy/misconfiguration.go: -------------------------------------------------------------------------------- 1 | package trivy 2 | 3 | type MisconfSummary struct { 4 | Successes int 5 | Failures int 6 | Exceptions int 7 | } 8 | 9 | type MisconfStatus string 10 | 11 | // DetectedMisconfiguration holds detected misconfigurations 12 | type DetectedMisconfiguration struct { 13 | Type string `json:",omitempty"` 14 | ID string `json:",omitempty"` 15 | AVDID string `json:",omitempty"` 16 | Title string `json:",omitempty"` 17 | Description string `json:",omitempty"` 18 | Message string `json:",omitempty"` 19 | Namespace string `json:",omitempty"` 20 | Query string `json:",omitempty"` 21 | Resolution string `json:",omitempty"` 22 | Severity string `json:",omitempty"` 23 | PrimaryURL string `json:",omitempty"` 24 | References []string `json:",omitempty"` 25 | Status MisconfStatus `json:",omitempty"` 26 | Layer Layer `json:",omitempty"` 27 | CauseMetadata CauseMetadata `json:",omitempty"` 28 | 29 | // For debugging 30 | Traces []string `json:",omitempty"` 31 | } 32 | 33 | type CauseMetadata struct { 34 | Resource string `json:",omitempty"` 35 | Provider string `json:",omitempty"` 36 | Service string `json:",omitempty"` 37 | StartLine int `json:",omitempty"` 38 | EndLine int `json:",omitempty"` 39 | Code Code `json:",omitempty"` 40 | Occurrences []Occurrence `json:",omitempty"` 41 | } 42 | 43 | type Occurrence struct { 44 | Resource string `json:",omitempty"` 45 | Filename string `json:",omitempty"` 46 | Location Location 47 | } 48 | 49 | type Code struct { 50 | Lines []Line 51 | } 52 | 53 | type Line struct { 54 | Number int `json:"Number"` 55 | Content string `json:"Content"` 56 | IsCause bool `json:"IsCause"` 57 | Annotation string `json:"Annotation"` 58 | Truncated bool `json:"Truncated"` 59 | Highlighted string `json:"Highlighted,omitempty"` 60 | FirstCause bool `json:"FirstCause"` 61 | LastCause bool `json:"LastCause"` 62 | } 63 | -------------------------------------------------------------------------------- /pkg/domain/model/github.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/m-mizutani/goerr/v2" 7 | "github.com/m-mizutani/octovy/pkg/domain/types" 8 | ) 9 | 10 | type GitHubRepo struct { 11 | RepoID int64 `json:"repo_id" bigquery:"repo_id"` 12 | Owner string `json:"owner" bigquery:"owner"` 13 | RepoName string `json:"repo_name" bigquery:"repo_name"` 14 | } 15 | 16 | func (x *GitHubRepo) Validate() error { 17 | if x.RepoID == 0 { 18 | return goerr.Wrap(types.ErrValidationFailed, "repo ID is empty") 19 | } 20 | if x.Owner == "" { 21 | return goerr.Wrap(types.ErrValidationFailed, "owner is empty") 22 | } 23 | if x.RepoName == "" { 24 | return goerr.Wrap(types.ErrValidationFailed, "repo name is empty") 25 | } 26 | 27 | return nil 28 | } 29 | 30 | type GitHubCommit struct { 31 | GitHubRepo 32 | Committer GitHubUser `json:"committer" bigquery:"committer"` 33 | CommitID string `json:"commit_id" bigquery:"commit_id"` 34 | Branch string `json:"branch" bigquery:"branch"` 35 | Ref string `json:"ref" bigquery:"ref"` 36 | } 37 | 38 | type GitHubMetadata struct { 39 | GitHubCommit 40 | PullRequest *GitHubPullRequest `json:"pull_request"` 41 | DefaultBranch string `json:"default_branch"` 42 | } 43 | 44 | type GitHubPullRequest struct { 45 | ID int64 `json:"id"` 46 | Number int `json:"number"` 47 | BaseBranch string `json:"base_branch"` 48 | BaseCommitID string `json:"base_commit_id"` 49 | User GitHubUser `json:"user"` 50 | } 51 | 52 | type GitHubUser struct { 53 | ID int64 `json:"id"` 54 | Login string `json:"login"` 55 | Email string `json:"email"` 56 | } 57 | 58 | var ( 59 | ptnValidCommitID = regexp.MustCompile("^[0-9a-f]{40}$") 60 | ) 61 | 62 | func (x *GitHubCommit) Validate() error { 63 | if err := x.GitHubRepo.Validate(); err != nil { 64 | return err 65 | } 66 | 67 | if !ptnValidCommitID.MatchString(x.CommitID) { 68 | return goerr.Wrap(types.ErrValidationFailed, "invalid commit ID") 69 | } 70 | 71 | return nil 72 | } 73 | 74 | type GitHubIssueComment struct { 75 | ID string 76 | Login string 77 | Body string 78 | IsMinimized bool 79 | } 80 | -------------------------------------------------------------------------------- /pkg/controller/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | 6 | "log/slog" 7 | 8 | "github.com/go-chi/chi/v5" 9 | "github.com/m-mizutani/octovy/pkg/domain/interfaces" 10 | "github.com/m-mizutani/octovy/pkg/domain/types" 11 | "github.com/m-mizutani/octovy/pkg/utils" 12 | ) 13 | 14 | type Server struct { 15 | mux *chi.Mux 16 | } 17 | 18 | func safeWrite(w http.ResponseWriter, code int, body []byte) { 19 | w.WriteHeader(code) 20 | 21 | // nosemgrep: go.lang.security.audit.xss.no-direct-write-to-responsewriter.no-direct-write-to-responsewriter 22 | // Why: The response data is not from user input 23 | if _, err := w.Write(body); err != nil { 24 | utils.Logger().Error("fail to write response", slog.Any("error", err)) 25 | } 26 | } 27 | 28 | type config struct { 29 | ghSecret types.GitHubAppSecret 30 | } 31 | 32 | type Option func(*config) 33 | 34 | func WithGitHubSecret(secret types.GitHubAppSecret) Option { 35 | return func(cfg *config) { 36 | cfg.ghSecret = secret 37 | } 38 | } 39 | 40 | func New(uc interfaces.UseCase, options ...Option) *Server { 41 | cfg := &config{} 42 | for _, opt := range options { 43 | opt(cfg) 44 | } 45 | 46 | r := chi.NewRouter() 47 | r.Use(preProcess) 48 | r.Get("/health", func(w http.ResponseWriter, r *http.Request) { 49 | safeWrite(w, http.StatusOK, []byte("ok")) 50 | }) 51 | r.Route("/webhook", func(r chi.Router) { 52 | r.Route("/github", func(r chi.Router) { 53 | r.Post("/app", func(w http.ResponseWriter, r *http.Request) { 54 | if err := handleGitHubAppEvent(uc, r, cfg.ghSecret); err != nil { 55 | utils.HandleError(r.Context(), "fail to handle GitHub App event", err) 56 | safeWrite(w, http.StatusInternalServerError, []byte(err.Error())) 57 | return 58 | } 59 | 60 | safeWrite(w, http.StatusOK, []byte("ok")) 61 | }) 62 | r.Post("/action", func(w http.ResponseWriter, r *http.Request) { 63 | if err := handleGitHubActionEvent(uc, r); err != nil { 64 | utils.HandleError(r.Context(), "fail to handle GitHub action event", err) 65 | safeWrite(w, http.StatusInternalServerError, []byte(err.Error())) 66 | return 67 | } 68 | 69 | safeWrite(w, http.StatusOK, []byte("ok")) 70 | }) 71 | }) 72 | }) 73 | 74 | return &Server{ 75 | mux: r, 76 | } 77 | } 78 | 79 | func (x *Server) Mux() *chi.Mux { 80 | return x.mux 81 | } 82 | -------------------------------------------------------------------------------- /pkg/usecase/templates/comment_body.md: -------------------------------------------------------------------------------- 1 | {{ .Signature }} 2 | {{ if eq .Metadata.TotalVulnCount 0 }} 3 | 🎉 **No vulnerability detected** 🎉 4 | {{ else if eq .Metadata.FixableVulnCount 0 }} 5 | 👍 **No fixable vulnerability detected** 👍 6 | {{ end }} 7 | 8 | {{ if .Added }} 9 | ## 🚨 New Vulnerabilities 10 | {{ range .Added }} 11 | ### {{ .Target }} 12 | {{ range .Vulnerabilities }} 13 |
14 | {{ .VulnerabilityID }}: {{ .Title }} ({{.Severity}}) 15 | 16 | - **PkgName**: {{ if .PkgName }}`{{ .PkgName }}`{{ else }}N/A{{ end }} 17 | - **Installed Version**: {{ if .InstalledVersion }}`{{ .InstalledVersion }}`{{ else }}N/A{{ end }} 18 | - **Fixed Version**: {{ if .FixedVersion }}`{{ .FixedVersion }}`{{ else }}N/A{{ end }} 19 | - **Status**: {{ if .Status }}`{{ .Status }}`{{ else }}N/A{{ end }} 20 | - **Severity**: {{ if .Severity }}`{{ .Severity }}`{{ else }}N/A{{ end }} 21 | 22 | #### Description 23 | 24 | {{ .Description }} 25 | 26 | #### References 27 | {{ range .References }} 28 | - [{{ . }}]({{ . }}){{ end }} 29 |
30 | {{ end }}{{ end }}{{ end }} 31 | 32 | {{ if .Fixed }} 33 | ## ✅ Fix Vulnerabilities 34 | {{ range .Fixed }} 35 | ### {{ .Target }} 36 | {{ range .Vulnerabilities }} 37 |
38 | {{ .VulnerabilityID }}: {{ .Title }} ({{.Severity}}) 39 | 40 | - **PkgName**: {{ if .PkgName }}`{{ .PkgName }}`{{ else }}N/A{{ end }} 41 | - **Installed Version**: {{ if .InstalledVersion }}`{{ .InstalledVersion }}`{{ else }}N/A{{ end }} 42 | - **Fixed Version**: {{ if .FixedVersion }}`{{ .FixedVersion }}`{{ else }}N/A{{ end }} 43 | - **Status**: {{ if .Status }}`{{ .Status }}`{{ else }}N/A{{ end }} 44 | - **Severity**: {{ if .Severity }}`{{ .Severity }}`{{ else }}N/A{{ end }} 45 | 46 | #### Description 47 | 48 | {{ .Description }} 49 | 50 | #### References 51 | 52 | {{ range .References }} 53 | - [{{ . }}]({{ . }}){{ end }} 54 |
55 | {{ end }}{{ end }}{{ end }} 56 | 57 | {{ if ne .Metadata.TotalVulnCount 0 }} 58 | ## ⚠️ All detected vulnerabilities 59 | {{ range .Report.Results }} 60 | 61 | {{ if gt (len .Vulnerabilities) 0 }} 62 |
63 | {{ .Target }}: ({{ .Vulnerabilities | len }}) 64 | 65 | {{ range .Vulnerabilities }}- {{ .VulnerabilityID }}: ( `{{ .PkgName }}` ) {{ .Title }} 66 | {{ end }} 67 |
68 | {{ end }} 69 | 70 | {{ if gt (len .Secrets) 0 }} 71 |
72 | {{ .Target }}: ({{ .Secrets | len }}) 73 | 74 | {{ range .Secrets }}- `{{ .RuleID }}`: {{ .Title }} ({{ .StartLine }}L-{{ .EndLine }}L) 75 | {{ end }} 76 |
77 | {{ end }} 78 | 79 | {{ end }} 80 | {{ end }} 81 | -------------------------------------------------------------------------------- /pkg/controller/cli/insert.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/m-mizutani/goerr/v2" 10 | "github.com/m-mizutani/gots/slice" 11 | "github.com/m-mizutani/octovy/pkg/controller/cli/config" 12 | "github.com/m-mizutani/octovy/pkg/domain/model" 13 | "github.com/m-mizutani/octovy/pkg/domain/model/trivy" 14 | "github.com/m-mizutani/octovy/pkg/infra" 15 | "github.com/m-mizutani/octovy/pkg/usecase" 16 | "github.com/urfave/cli/v2" 17 | ) 18 | 19 | func insertCommand() *cli.Command { 20 | var ( 21 | bigQuery config.BigQuery 22 | filePath string 23 | meta model.GitHubMetadata 24 | ) 25 | 26 | return &cli.Command{ 27 | Name: "insert", 28 | Aliases: []string{"i"}, 29 | Usage: "Insert trivy scan result JSON file to BigQuery", 30 | Flags: slice.Flatten([]cli.Flag{ 31 | &cli.StringFlag{ 32 | Name: "file", 33 | Aliases: []string{"f"}, 34 | Usage: "Path to JSON file generated by trivy. '-' is stdin", 35 | Value: "-", 36 | Destination: &filePath, 37 | }, 38 | 39 | &cli.StringFlag{ 40 | Name: "github-owner", 41 | Usage: "GitHub repository owner", 42 | EnvVars: []string{"OCTOVY_GITHUB_OWNER"}, 43 | Destination: &meta.Owner, 44 | Required: true, 45 | }, 46 | &cli.StringFlag{ 47 | Name: "github-repo", 48 | Usage: "GitHub repository name", 49 | EnvVars: []string{"OCTOVY_GITHUB_REPO"}, 50 | Destination: &meta.RepoName, 51 | Required: true, 52 | }, 53 | &cli.StringFlag{ 54 | Name: "github-commit-id", 55 | Usage: "GitHub commit ID", 56 | EnvVars: []string{"OCTOVY_GITHUB_COMMIT_ID"}, 57 | Destination: &meta.CommitID, 58 | Required: true, 59 | }, 60 | }, bigQuery.Flags()), 61 | Action: func(c *cli.Context) error { 62 | ctx := c.Context 63 | 64 | bqClient, err := bigQuery.NewClient(ctx) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | clients := infra.New(infra.WithBigQuery(bqClient)) 70 | uc := usecase.New(clients) 71 | 72 | var r io.Reader 73 | switch filePath { 74 | case "-": 75 | r = c.App.Reader 76 | default: 77 | f, err := os.Open(filepath.Clean(filePath)) 78 | if err != nil { 79 | return goerr.Wrap(err, "failed to open file", goerr.V("file", filePath)) 80 | } 81 | defer f.Close() 82 | r = f 83 | } 84 | 85 | var report trivy.Report 86 | if err := json.NewDecoder(r).Decode(&report); err != nil { 87 | return goerr.Wrap(err, "failed to parse trivy result data") 88 | } 89 | 90 | if err := uc.InsertScanResult(ctx, meta, report, model.Config{}); err != nil { 91 | return goerr.Wrap(err, "failed to insert scan result", goerr.V("file", filePath)) 92 | } 93 | 94 | return nil 95 | }, 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /pkg/controller/cli/config/bq.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "github.com/m-mizutani/goerr/v2" 8 | "github.com/m-mizutani/octovy/pkg/domain/interfaces" 9 | "github.com/m-mizutani/octovy/pkg/domain/types" 10 | "github.com/m-mizutani/octovy/pkg/infra/bq" 11 | "github.com/urfave/cli/v2" 12 | "google.golang.org/api/impersonate" 13 | "google.golang.org/api/option" 14 | ) 15 | 16 | type BigQuery struct { 17 | projectID types.GoogleProjectID 18 | datasetID types.BQDatasetID 19 | tableID types.BQTableID 20 | impersonateServiceAccount string 21 | } 22 | 23 | func (x *BigQuery) Flags() []cli.Flag { 24 | return []cli.Flag{ 25 | &cli.StringFlag{ 26 | Name: "bigquery-project-id", 27 | Usage: "BigQuery project ID", 28 | Category: "BigQuery", 29 | Destination: (*string)(&x.projectID), 30 | EnvVars: []string{"OCTOVY_BIGQUERY_PROJECT_ID"}, 31 | }, 32 | &cli.StringFlag{ 33 | Name: "bigquery-dataset-id", 34 | Usage: "BigQuery dataset ID", 35 | Category: "BigQuery", 36 | Destination: (*string)(&x.datasetID), 37 | EnvVars: []string{"OCTOVY_BIGQUERY_DATASET_ID"}, 38 | }, 39 | &cli.StringFlag{ 40 | Name: "bigquery-table-id", 41 | Usage: "BigQuery table ID", 42 | Category: "BigQuery", 43 | Destination: (*string)(&x.tableID), 44 | EnvVars: []string{"OCTOVY_BIGQUERY_TABLE_ID"}, 45 | Value: "scans", 46 | }, 47 | &cli.StringFlag{ 48 | Name: "bq-impersonate-service-account", 49 | Usage: "Impersonate service account for BigQuery", 50 | Destination: &x.impersonateServiceAccount, 51 | EnvVars: []string{"OCTOVY_BIGQUERY_IMPERSONATE_SERVICE_ACCOUNT"}, 52 | }, 53 | } 54 | } 55 | 56 | func (x *BigQuery) TableID() types.BQTableID { 57 | return x.tableID 58 | } 59 | 60 | func (x *BigQuery) LogValue() slog.Value { 61 | return slog.GroupValue( 62 | slog.Any("ProjectID", x.projectID), 63 | slog.Any("DatasetID", x.datasetID), 64 | slog.Any("TableID", x.tableID), 65 | slog.Any("ImpersonateServiceAccount", x.impersonateServiceAccount), 66 | ) 67 | } 68 | 69 | func (x *BigQuery) NewClient(ctx context.Context) (interfaces.BigQuery, error) { 70 | if x.projectID == "" && x.datasetID == "" { 71 | return nil, nil 72 | } 73 | var options []option.ClientOption 74 | if x.impersonateServiceAccount != "" { 75 | ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ 76 | TargetPrincipal: x.impersonateServiceAccount, 77 | Scopes: []string{ 78 | "https://www.googleapis.com/auth/bigquery", 79 | "https://www.googleapis.com/auth/cloud-platform", 80 | }, 81 | }) 82 | if err != nil { 83 | return nil, goerr.Wrap(err, "failed to create token source for impersonate") 84 | } 85 | 86 | options = append(options, option.WithTokenSource(ts)) 87 | } 88 | 89 | return bq.New(ctx, x.projectID, x.datasetID, options...) 90 | } 91 | -------------------------------------------------------------------------------- /pkg/utils/logger.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | 8 | "log/slog" 9 | 10 | "github.com/fatih/color" 11 | "github.com/m-mizutani/clog" 12 | "github.com/m-mizutani/goerr/v2" 13 | "github.com/m-mizutani/masq" 14 | "github.com/m-mizutani/octovy/pkg/domain/types" 15 | ) 16 | 17 | var logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) 18 | 19 | func init() { 20 | _ = ReconfigureLogger("text", "info", "stdout") 21 | } 22 | 23 | func Logger() *slog.Logger { 24 | return logger 25 | } 26 | 27 | func ReconfigureLogger(logFormat, logLevel, logOutput string) error { 28 | filter := masq.New( 29 | // Mask value with `masq:"secret"` tag 30 | masq.WithTag("secret"), 31 | masq.WithType[types.GitHubAppSecret](masq.MaskWithSymbol('*', 64)), 32 | masq.WithType[types.GitHubAppPrivateKey](masq.MaskWithSymbol('*', 16)), 33 | ) 34 | 35 | levelMap := map[string]slog.Level{ 36 | "debug": slog.LevelDebug, 37 | "info": slog.LevelInfo, 38 | "warn": slog.LevelWarn, 39 | "error": slog.LevelError, 40 | } 41 | 42 | level, ok := levelMap[logLevel] 43 | if !ok { 44 | return goerr.Wrap(types.ErrInvalidOption, "invalid log level", goerr.V("value", logLevel)) 45 | } 46 | 47 | var w io.Writer 48 | switch logOutput { 49 | case "stdout", "-": 50 | w = os.Stdout 51 | case "stderr": 52 | w = os.Stderr 53 | default: 54 | fd, err := os.Create(filepath.Clean(logOutput)) 55 | if err != nil { 56 | return goerr.Wrap(err, "failed to open log file", goerr.V("path", logOutput)) 57 | } 58 | w = fd 59 | } 60 | 61 | var handler slog.Handler 62 | switch logFormat { 63 | case "text": 64 | handler = clog.New( 65 | clog.WithWriter(w), 66 | clog.WithLevel(level), 67 | // clog.WithReplaceAttr(filter), 68 | clog.WithSource(true), 69 | // clog.WithTimeFmt("2006-01-02 15:04:05"), 70 | clog.WithColorMap(&clog.ColorMap{ 71 | Level: map[slog.Level]*color.Color{ 72 | slog.LevelDebug: color.New(color.FgGreen, color.Bold), 73 | slog.LevelInfo: color.New(color.FgCyan, color.Bold), 74 | slog.LevelWarn: color.New(color.FgYellow, color.Bold), 75 | slog.LevelError: color.New(color.FgRed, color.Bold), 76 | }, 77 | LevelDefault: color.New(color.FgBlue, color.Bold), 78 | Time: color.New(color.FgWhite), 79 | Message: color.New(color.FgHiWhite), 80 | AttrKey: color.New(color.FgHiCyan), 81 | AttrValue: color.New(color.FgHiWhite), 82 | }), 83 | clog.WithReplaceAttr(filter), 84 | ) 85 | 86 | case "json": 87 | handler = slog.NewJSONHandler(w, &slog.HandlerOptions{ 88 | AddSource: true, 89 | Level: level, 90 | ReplaceAttr: filter, 91 | }) 92 | 93 | default: 94 | return goerr.Wrap(types.ErrInvalidOption, "invalid log format, should be 'json' or 'text'", goerr.V("value", logFormat)) 95 | } 96 | 97 | logger = slog.New(handler) 98 | 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /pkg/infra/bq/data.json: -------------------------------------------------------------------------------- 1 | {"id":"","github":{"repo_id":0,"owner":"","repo_name":"","committer":{"id":0,"login":"","email":""},"commit_id":"","branch":"","ref":"","pull_request":null,"default_branch":""},"report":{"SchemaVersion":2,"ArtifactName":".","ArtifactType":"filesystem","Metadata":{"ImageConfig":{"architecture":"","created":"0001-01-01T00:00:00Z","os":"","rootfs":{"type":"","diff_ids":null},"config":{}}},"Results":[{"Target":"Gemfile.lock","Class":"lang-pkgs","Type":"bundler","Packages":[{"ID":"octovy-test@0.1.0","Name":"octovy-test","Version":"0.1.0","Indirect":true,"Layer":{},"Locations":[{"StartLine":4,"EndLine":4}]},{"ID":"rake@10.5.0","Name":"rake","Version":"10.5.0","Layer":{},"Locations":[{"StartLine":9,"EndLine":9}]}],"Vulnerabilities":[{"VulnerabilityID":"CVE-2020-8130","PkgID":"rake@10.5.0","PkgName":"rake","InstalledVersion":"10.5.0","FixedVersion":"\u003e= 12.3.3","Status":"fixed","Layer":{},"SeveritySource":"ruby-advisory-db","PrimaryURL":"https://avd.aquasec.com/nvd/cve-2020-8130","DataSource":{"ID":"ruby-advisory-db","Name":"Ruby Advisory Database","URL":"https://github.com/rubysec/ruby-advisory-db"},"Title":"rake: OS Command Injection via egrep in Rake::FileList","Description":"There is an OS command injection vulnerability in Ruby Rake \u003c 12.3.3 in Rake::FileList when supplying a filename that begins with the pipe character `|`.","Severity":"HIGH","CweIDs":["CWE-78"],"CVSS":{"ghsa":{"V3Vector":"CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H","V3Score":6.4},"nvd":{"V2Vector":"AV:L/AC:M/Au:N/C:C/I:C/A:C","V3Vector":"CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H","V2Score":6.9,"V3Score":6.4},"redhat":{"V3Vector":"CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H","V3Score":6.4}},"References":["http://lists.opensuse.org/opensuse-security-announce/2020-03/msg00041.html","https://access.redhat.com/security/cve/CVE-2020-8130","https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-8130","https://github.com/advisories/GHSA-jppv-gw3r-w3q8","https://github.com/ruby/rake","https://github.com/ruby/rake/commit/5b8f8fc41a5d7d7d6a5d767e48464c60884d3aee","https://github.com/rubysec/ruby-advisory-db/blob/master/gems/rake/CVE-2020-8130.yml","https://hackerone.com/reports/651518","https://lists.debian.org/debian-lts-announce/2020/02/msg00026.html","https://lists.fedoraproject.org/archives/list/package-announce%40lists.fedoraproject.org/message/523CLQ62VRN3VVC52KMPTROCCKY4Z36B/","https://lists.fedoraproject.org/archives/list/package-announce%40lists.fedoraproject.org/message/VXMX4ARNX2JLRJMSH4N3J3UBMUT5CI44/","https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/523CLQ62VRN3VVC52KMPTROCCKY4Z36B","https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/VXMX4ARNX2JLRJMSH4N3J3UBMUT5CI44","https://nvd.nist.gov/vuln/detail/CVE-2020-8130","https://ubuntu.com/security/notices/USN-4295-1","https://usn.ubuntu.com/4295-1","https://usn.ubuntu.com/4295-1/","https://www.cve.org/CVERecord?id=CVE-2020-8130"],"PublishedDate":"2020-02-24T15:15:11.957Z","LastModifiedDate":"2023-11-07T03:26:16.5Z"}]}]},"timestamp":-62135596800000000} 2 | -------------------------------------------------------------------------------- /pkg/infra/gh/client_test.go: -------------------------------------------------------------------------------- 1 | package gh_test 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/google/uuid" 9 | "github.com/m-mizutani/gt" 10 | "github.com/m-mizutani/octovy/pkg/domain/model" 11 | "github.com/m-mizutani/octovy/pkg/domain/types" 12 | "github.com/m-mizutani/octovy/pkg/infra/gh" 13 | "github.com/m-mizutani/octovy/pkg/utils" 14 | ) 15 | 16 | func TestGitHubComment(t *testing.T) { 17 | ghApp, installID := buildGitHubApp(t) 18 | 19 | ctx := context.Background() 20 | repo := &model.GitHubRepo{ 21 | Owner: "m-mizutani", 22 | RepoName: "octovy-test-code", 23 | } 24 | ghApp.CreateIssueComment(ctx, repo, types.GitHubAppInstallID(installID), 1, "Hello, world") 25 | 26 | comments := gt.R1(ghApp.ListIssueComments(ctx, repo, types.GitHubAppInstallID(installID), 1)).NoError(t) 27 | 28 | utils.DumpJson(t, "comments.json", comments) 29 | } 30 | 31 | func buildGitHubApp(t *testing.T) (*gh.Client, types.GitHubAppInstallID) { 32 | var ( 33 | strAppID = utils.LoadEnv(t, "TEST_GITHUB_APP_ID") 34 | strInstallationID = utils.LoadEnv(t, "TEST_GITHUB_INSTALLATION_ID") 35 | privateKey = utils.LoadEnv(t, "TEST_GITHUB_PRIVATE_KEY") 36 | ) 37 | 38 | appID := gt.R1(strconv.ParseInt(strAppID, 10, 64)).NoError(t) 39 | installID := gt.R1(strconv.ParseInt(strInstallationID, 10, 64)).NoError(t) 40 | 41 | ghApp := gt.R1(gh.New(types.GitHubAppID(appID), types.GitHubAppPrivateKey(privateKey), gh.WithEnableCheckRuns(true))).NoError(t) 42 | 43 | return ghApp, types.GitHubAppInstallID(installID) 44 | } 45 | 46 | func TestListComments(t *testing.T) { 47 | ghApp, installID := buildGitHubApp(t) 48 | 49 | ctx := context.Background() 50 | repo := &model.GitHubRepo{ 51 | Owner: "m-mizutani", 52 | RepoName: "octovy-test-code", 53 | } 54 | 55 | comments, err := ghApp.ListIssueComments(ctx, repo, types.GitHubAppInstallID(installID), 2) 56 | gt.NoError(t, err) 57 | gt.A(t, comments).Longer(1).At(0, func(t testing.TB, v *model.GitHubIssueComment) { 58 | gt.Equal(t, v.Body, "testing") 59 | }) 60 | } 61 | 62 | func TestHideComment(t *testing.T) { 63 | ghApp, installID := buildGitHubApp(t) 64 | 65 | ctx := context.Background() 66 | repo := &model.GitHubRepo{ 67 | Owner: "m-mizutani", 68 | RepoName: "octovy-test-code", 69 | } 70 | testIssueID := 2 71 | 72 | slag := "comment-test:" + uuid.NewString() 73 | gt.NoError(t, ghApp.CreateIssueComment(ctx, repo, installID, 2, slag)) 74 | 75 | comments, err := ghApp.ListIssueComments(ctx, repo, types.GitHubAppInstallID(installID), testIssueID) 76 | gt.NoError(t, err) 77 | 78 | var subjectID string 79 | for _, c := range comments { 80 | if c.Body == slag { 81 | gt.False(t, c.IsMinimized) 82 | subjectID = c.ID 83 | break 84 | } 85 | } 86 | 87 | gt.NotEqual(t, subjectID, "") 88 | 89 | gt.NoError(t, ghApp.MinimizeComment(ctx, repo, installID, subjectID)) 90 | 91 | comments, err = ghApp.ListIssueComments(ctx, repo, types.GitHubAppInstallID(installID), testIssueID) 92 | gt.NoError(t, err) 93 | gt.A(t, comments).Longer(1).Any(func(v *model.GitHubIssueComment) bool { 94 | return v.ID == subjectID && v.IsMinimized 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /pkg/infra/bq/client_test.go: -------------------------------------------------------------------------------- 1 | package bq_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "cloud.google.com/go/bigquery" 9 | "github.com/m-mizutani/bqs" 10 | "github.com/m-mizutani/gt" 11 | "github.com/m-mizutani/octovy/pkg/domain/model" 12 | "github.com/m-mizutani/octovy/pkg/domain/types" 13 | "github.com/m-mizutani/octovy/pkg/infra/bq" 14 | "github.com/m-mizutani/octovy/pkg/utils" 15 | "google.golang.org/api/impersonate" 16 | "google.golang.org/api/option" 17 | ) 18 | 19 | func TestClient(t *testing.T) { 20 | projectID := utils.LoadEnv(t, "TEST_BIGQUERY_PROJECT_ID") 21 | datasetID := utils.LoadEnv(t, "TEST_BIGQUERY_DATASET_ID") 22 | 23 | ctx := context.Background() 24 | 25 | tblName := types.BQTableID(time.Now().Format("insert_test_20060102_150405")) 26 | client, err := bq.New(ctx, types.GoogleProjectID(projectID), types.BQDatasetID(datasetID)) 27 | gt.NoError(t, err) 28 | 29 | var baseSchema bigquery.Schema 30 | 31 | t.Run("Create base table at first", func(t *testing.T) { 32 | var scan model.Scan 33 | baseSchema = gt.R1(bqs.Infer(scan)).NoError(t) 34 | gt.NoError(t, err) 35 | 36 | gt.NoError(t, client.CreateTable(ctx, tblName, &bigquery.TableMetadata{ 37 | Name: tblName.String(), 38 | Schema: baseSchema, 39 | })) 40 | }) 41 | 42 | t.Run("Insert record", func(t *testing.T) { 43 | var scan model.Scan 44 | utils.LoadJson(t, "testdata/data.json", &scan.Report) 45 | dataSchema := gt.R1(bqs.Infer(scan)).NoError(t) 46 | mergedSchema := gt.R1(bqs.Merge(baseSchema, dataSchema)).NoError(t) 47 | 48 | md := gt.R1(client.GetMetadata(ctx, tblName)).NoError(t) 49 | gt.False(t, bqs.Equal(mergedSchema, baseSchema)) 50 | gt.NoError(t, client.UpdateTable(ctx, tblName, bigquery.TableMetadataToUpdate{ 51 | Schema: mergedSchema, 52 | }, md.ETag)).Must() 53 | 54 | record := model.ScanRawRecord{ 55 | Scan: scan, 56 | Timestamp: scan.Timestamp.UnixMicro(), 57 | } 58 | gt.NoError(t, client.Insert(ctx, tblName, mergedSchema, record)) 59 | }) 60 | } 61 | 62 | func TestImpersonation(t *testing.T) { 63 | projectID := utils.LoadEnv(t, "TEST_BIGQUERY_PROJECT_ID") 64 | datasetID := utils.LoadEnv(t, "TEST_BIGQUERY_DATASET_ID") 65 | serviceAccount := utils.LoadEnv(t, "TEST_BIGQUERY_IMPERSONATE_SERVICE_ACCOUNT") 66 | 67 | ctx := context.Background() 68 | 69 | ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ 70 | TargetPrincipal: serviceAccount, 71 | Scopes: []string{ 72 | "https://www.googleapis.com/auth/bigquery", 73 | "https://www.googleapis.com/auth/cloud-platform", 74 | }, 75 | }) 76 | gt.NoError(t, err) 77 | 78 | client, err := bq.New(ctx, types.GoogleProjectID(projectID), types.BQDatasetID(datasetID), option.WithTokenSource(ts)) 79 | gt.NoError(t, err) 80 | 81 | msg := struct { 82 | Msg string 83 | }{ 84 | Msg: "Hello, BigQuery: " + time.Now().String(), 85 | } 86 | 87 | schema := gt.R1(bqs.Infer(msg)).NoError(t) 88 | 89 | tblName := types.BQTableID(time.Now().Format("impersonation_test_20060102_150405")) 90 | gt.NoError(t, client.CreateTable(ctx, tblName, &bigquery.TableMetadata{ 91 | Name: tblName.String(), 92 | Schema: schema, 93 | })) 94 | 95 | gt.NoError(t, client.Insert(ctx, tblName, schema, msg)) 96 | } 97 | -------------------------------------------------------------------------------- /pkg/domain/model/config.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | _ "embed" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "cuelang.org/go/cue/cuecontext" 10 | "github.com/m-mizutani/goerr/v2" 11 | ) 12 | 13 | type Config struct { 14 | IgnoreList []IgnoreConfig 15 | } 16 | 17 | //go:embed schema/ignore.cue 18 | var ignoreCue []byte 19 | 20 | type IgnoreConfig struct { 21 | Target string 22 | Vulns []IgnoreVuln 23 | } 24 | 25 | type IgnoreVuln struct { 26 | ID string 27 | Comment string 28 | ExpiresAt time.Time 29 | } 30 | 31 | func (x IgnoreVuln) IsActive(now time.Time) bool { 32 | return x.ExpiresAt.Before(now) || x.ExpiresAt.After(now.AddDate(0, 0, 90)) 33 | } 34 | 35 | func BuildConfig(configData ...[]byte) (*Config, error) { 36 | ctx := cuecontext.New() 37 | 38 | // Load the schema 39 | schemaInstance := ctx.CompileBytes(ignoreCue) 40 | if schemaInstance.Err() != nil { 41 | return nil, goerr.Wrap(schemaInstance.Err(), "failed to compile schema") 42 | } 43 | 44 | for _, data := range configData { 45 | // Load the configuration 46 | configInstance := ctx.CompileBytes(data) 47 | if configInstance.Err() != nil { 48 | return nil, goerr.Wrap(configInstance.Err(), "failed to compile configuration") 49 | } 50 | 51 | // Merge the schema and config 52 | mergedInstance := schemaInstance.Unify(configInstance) 53 | if mergedInstance.Err() != nil { 54 | return nil, goerr.Wrap(mergedInstance.Err(), "failed to unify schema and config") 55 | } 56 | 57 | schemaInstance = mergedInstance 58 | } 59 | 60 | // Extract the configuration into a Go struct 61 | var config Config 62 | if err := schemaInstance.Value().Decode(&config); err != nil { 63 | return nil, goerr.Wrap(err, "failed to decode configuration") 64 | } 65 | 66 | return &config, nil 67 | } 68 | 69 | // LoadConfigsFromDir loads configuration files from the repository. The configuration files are used to scan the repository with Trivy. The configuration .cue files are read recursively from the root directory of the repository. 70 | func LoadConfigsFromDir(path string) (*Config, error) { 71 | // If path does not exist, return an empty configuration 72 | if _, err := os.Stat(path); os.IsNotExist(err) { 73 | return &Config{}, nil 74 | } 75 | 76 | // read .cue files recursively from the root directory 77 | var configData [][]byte 78 | 79 | err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error { 80 | if err != nil { 81 | return err 82 | } 83 | if info.IsDir() { 84 | return nil 85 | } 86 | if filepath.Ext(filepath.Clean(filePath)) == ".cue" { 87 | data, err := os.ReadFile(filepath.Clean(filePath)) 88 | if err != nil { 89 | return goerr.Wrap(err, "failed to read file", goerr.V("path", filePath)) 90 | } 91 | configData = append(configData, data) 92 | } 93 | return nil 94 | }) 95 | 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | // If no configuration files are found, return an empty configuration 101 | if len(configData) == 0 { 102 | return &Config{}, nil 103 | } 104 | 105 | cfg, err := BuildConfig(configData...) 106 | if err != nil { 107 | return nil, goerr.Wrap(err, "failed to load config") 108 | } 109 | 110 | return cfg, nil 111 | } 112 | -------------------------------------------------------------------------------- /pkg/domain/model/trivy/vulnerability.go: -------------------------------------------------------------------------------- 1 | package trivy 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | ) 8 | 9 | // DetectedVulnerability holds the information of detected vulnerabilities 10 | type DetectedVulnerability struct { 11 | VulnerabilityID string `json:",omitempty"` 12 | VendorIDs []string `json:",omitempty"` 13 | PkgID string `json:",omitempty"` // It is used to construct dependency graph. 14 | PkgName string `json:",omitempty"` 15 | PkgPath string `json:",omitempty"` // This field is populated in the case of language-specific packages such as egg/wheel and gemspec 16 | InstalledVersion string `json:",omitempty"` 17 | FixedVersion string `json:",omitempty"` 18 | Status string `json:",omitempty"` 19 | Layer Layer `json:",omitempty"` 20 | SeveritySource SourceID `json:",omitempty"` 21 | PrimaryURL string `json:",omitempty"` 22 | 23 | // PkgRef is populated only when scanning SBOM and contains the reference ID used in the SBOM. 24 | // It could be PURL, UUID, etc. 25 | // e.g. 26 | // - pkg:npm/acme/component@1.0.0 27 | // - b2a46a4b-8367-4bae-9820-95557cfe03a8 28 | PkgRef string `json:",omitempty"` 29 | 30 | // DataSource holds where the advisory comes from 31 | DataSource *DataSource `json:",omitempty"` 32 | 33 | // Custom is for extensibility and not supposed to be used in OSS 34 | Custom interface{} `json:",omitempty"` 35 | 36 | // Embed vulnerability details 37 | Vulnerability 38 | } 39 | 40 | func (x *DetectedVulnerability) ID() string { 41 | raw := bytes.Join([][]byte{ 42 | []byte(x.VulnerabilityID), 43 | []byte(x.PkgName), 44 | []byte(x.PkgPath), 45 | []byte(x.PkgID), 46 | }, []byte{0x00}) 47 | 48 | h := sha256.New() 49 | h.Write(raw) 50 | return hex.EncodeToString((h.Sum(nil))) 51 | } 52 | 53 | type SourceID string 54 | 55 | type DataSource struct { 56 | ID SourceID `json:",omitempty"` 57 | Name string `json:",omitempty"` 58 | URL string `json:",omitempty"` 59 | } 60 | 61 | type Vulnerability struct { 62 | Title string `json:",omitempty"` 63 | Description string `json:",omitempty"` 64 | Severity string `json:",omitempty"` // Selected from VendorSeverity, depending on a scan target 65 | CweIDs []string `json:",omitempty"` // e.g. CWE-78, CWE-89 66 | 67 | // VenderSeverity is map and it may contain a field name with "-". It's not allowed in BigQuery and excluded. 68 | // VendorSeverity VendorSeverity `json:",omitempty"` 69 | 70 | CVSS VendorCVSS `json:",omitempty"` 71 | References []string `json:",omitempty"` 72 | PublishedDate string `json:",omitempty"` // Take from NVD 73 | LastModifiedDate string `json:",omitempty"` // Take from NVD 74 | 75 | // Custom is basically for extensibility and is not supposed to be used in OSS 76 | Custom interface{} `json:",omitempty"` 77 | } 78 | 79 | type Severity int 80 | 81 | // type VendorSeverity map[SourceID]Severity 82 | 83 | type CVSS struct { 84 | V2Vector string `json:"V2Vector,omitempty"` 85 | V3Vector string `json:"V3Vector,omitempty"` 86 | V2Score float64 `json:"V2Score,omitempty"` 87 | V3Score float64 `json:"V3Score,omitempty"` 88 | } 89 | 90 | type CVSSVector struct { 91 | V2 string `json:"v2,omitempty"` 92 | V3 string `json:"v3,omitempty"` 93 | } 94 | 95 | type VendorCVSS map[SourceID]CVSS 96 | -------------------------------------------------------------------------------- /pkg/usecase/testdata/trivy-result.json: -------------------------------------------------------------------------------- 1 | { 2 | "SchemaVersion": 2, 3 | "ArtifactName": "/var/folders/yl/q6mn9xxx7tz4xc_dlkx2w57m0000gn/T/octovy.m-mizutani.octovy.1234567890.3658582564", 4 | "ArtifactType": "filesystem", 5 | "Metadata": { 6 | "ImageConfig": { 7 | "architecture": "", 8 | "created": "0001-01-01T00:00:00Z", 9 | "os": "", 10 | "rootfs": { 11 | "type": "", 12 | "diff_ids": null 13 | }, 14 | "config": {} 15 | } 16 | }, 17 | "Results": [ 18 | { 19 | "Target": "Gemfile.lock", 20 | "Class": "lang-pkgs", 21 | "Type": "bundler", 22 | "Packages": [ 23 | { 24 | "Name": "octovy-test", 25 | "Version": "0.1.0", 26 | "Layer": {} 27 | }, 28 | { 29 | "Name": "rake", 30 | "Version": "10.5.0", 31 | "Layer": {} 32 | } 33 | ], 34 | "Vulnerabilities": [ 35 | { 36 | "VulnerabilityID": "CVE-2020-8130", 37 | "PkgName": "rake", 38 | "InstalledVersion": "10.5.0", 39 | "FixedVersion": "\u003e= 12.3.3", 40 | "Layer": {}, 41 | "SeveritySource": "ruby-advisory-db", 42 | "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2020-8130", 43 | "DataSource": { 44 | "ID": "ruby-advisory-db", 45 | "Name": "Ruby Advisory Database", 46 | "URL": "https://github.com/rubysec/ruby-advisory-db" 47 | }, 48 | "Title": "rake: OS Command Injection via egrep in Rake::FileList", 49 | "Description": "There is an OS command injection vulnerability in Ruby Rake \u003c 12.3.3 in Rake::FileList when supplying a filename that begins with the pipe character `|`.", 50 | "Severity": "HIGH", 51 | "CweIDs": [ 52 | "CWE-78" 53 | ], 54 | "CVSS": { 55 | "nvd": { 56 | "V2Vector": "AV:L/AC:M/Au:N/C:C/I:C/A:C", 57 | "V3Vector": "CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H", 58 | "V2Score": 6.9, 59 | "V3Score": 6.4 60 | }, 61 | "redhat": { 62 | "V3Vector": "CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H", 63 | "V3Score": 6.4 64 | } 65 | }, 66 | "References": [ 67 | "http://lists.opensuse.org/opensuse-security-announce/2020-03/msg00041.html", 68 | "https://access.redhat.com/security/cve/CVE-2020-8130", 69 | "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-8130", 70 | "https://github.com/advisories/GHSA-jppv-gw3r-w3q8", 71 | "https://github.com/ruby/rake/commit/5b8f8fc41a5d7d7d6a5d767e48464c60884d3aee", 72 | "https://hackerone.com/reports/651518", 73 | "https://lists.debian.org/debian-lts-announce/2020/02/msg00026.html", 74 | "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/523CLQ62VRN3VVC52KMPTROCCKY4Z36B/", 75 | "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/VXMX4ARNX2JLRJMSH4N3J3UBMUT5CI44/", 76 | "https://nvd.nist.gov/vuln/detail/CVE-2020-8130", 77 | "https://ubuntu.com/security/notices/USN-4295-1", 78 | "https://usn.ubuntu.com/4295-1/", 79 | "https://www.cve.org/CVERecord?id=CVE-2020-8130" 80 | ], 81 | "PublishedDate": "2020-02-24T15:15:00Z", 82 | "LastModifiedDate": "2020-06-30T14:00:00Z" 83 | } 84 | ] 85 | } 86 | ] 87 | } 88 | -------------------------------------------------------------------------------- /pkg/usecase/insert_scan_result.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "strings" 8 | "time" 9 | 10 | "cloud.google.com/go/bigquery" 11 | "github.com/m-mizutani/bqs" 12 | "github.com/m-mizutani/goerr/v2" 13 | "github.com/m-mizutani/octovy/pkg/domain/interfaces" 14 | "github.com/m-mizutani/octovy/pkg/domain/model" 15 | "github.com/m-mizutani/octovy/pkg/domain/model/trivy" 16 | "github.com/m-mizutani/octovy/pkg/domain/types" 17 | ) 18 | 19 | func (x *UseCase) InsertScanResult(ctx context.Context, meta model.GitHubMetadata, report trivy.Report, cfg model.Config) error { 20 | if err := report.Validate(); err != nil { 21 | return goerr.Wrap(err, "invalid trivy report") 22 | } 23 | 24 | cfgData, err := json.Marshal(cfg) 25 | if err != nil { 26 | return goerr.Wrap(err, "failed to marshal config") 27 | } 28 | 29 | scan := &model.Scan{ 30 | ID: types.NewScanID(), 31 | Timestamp: time.Now().UTC(), 32 | GitHub: meta, 33 | Report: report, 34 | Config: string(cfgData), 35 | } 36 | 37 | if x.clients.BigQuery() != nil { 38 | schema, err := createOrUpdateBigQueryTable(ctx, x.clients.BigQuery(), x.tableID, scan) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | rawRecord := &model.ScanRawRecord{ 44 | Scan: *scan, 45 | Timestamp: scan.Timestamp.UnixMicro(), 46 | } 47 | if err := x.clients.BigQuery().Insert(ctx, x.tableID, schema, rawRecord); err != nil { 48 | return goerr.Wrap(err, "failed to insert scan data to BigQuery") 49 | } 50 | } 51 | 52 | if x.clients.BigQuery() != nil { 53 | raw, err := json.Marshal(scan) 54 | if err != nil { 55 | return goerr.Wrap(err, "failed to marshal scan data") 56 | } 57 | 58 | commitKey := toStorageCommitKey(scan.GitHub) 59 | branchKey := toStorageBranchKey(scan.GitHub) 60 | 61 | for _, key := range []string{commitKey, branchKey} { 62 | buf := strings.NewReader(string(raw)) 63 | reader := io.NopCloser(buf) 64 | if err := x.clients.Storage().Put(ctx, key, reader); err != nil { 65 | return err 66 | } 67 | } 68 | } 69 | return nil 70 | } 71 | 72 | func toStorageCommitKey(meta model.GitHubMetadata) string { 73 | return strings.Join([]string{ 74 | meta.Owner, 75 | meta.RepoName, 76 | "commit", 77 | meta.CommitID, 78 | "scan.json.gz", 79 | }, "/") 80 | } 81 | 82 | func toStorageBranchKey(meta model.GitHubMetadata) string { 83 | return strings.Join([]string{ 84 | meta.Owner, 85 | meta.RepoName, 86 | "branch", 87 | meta.Branch, 88 | "scan.json.gz", 89 | }, "/") 90 | } 91 | 92 | func createOrUpdateBigQueryTable(ctx context.Context, bq interfaces.BigQuery, tableID types.BQTableID, scan *model.Scan) (bigquery.Schema, error) { 93 | schema, err := bqs.Infer(scan) 94 | if err != nil { 95 | return nil, goerr.Wrap(err, "failed to infer scan schema") 96 | } 97 | 98 | metaData, err := bq.GetMetadata(ctx, tableID) 99 | if err != nil { 100 | return nil, goerr.Wrap(err, "failed to create BigQuery table") 101 | } 102 | if metaData == nil { 103 | if err := bq.CreateTable(ctx, tableID, &bigquery.TableMetadata{ 104 | Schema: schema, 105 | }); err != nil { 106 | return nil, goerr.Wrap(err, "failed to create BigQuery table") 107 | } 108 | 109 | return schema, nil 110 | } 111 | 112 | if bqs.Equal(metaData.Schema, schema) { 113 | return schema, nil 114 | } 115 | 116 | mergedSchema, err := bqs.Merge(metaData.Schema, schema) 117 | if err != nil { 118 | return nil, goerr.Wrap(err, "failed to merge BigQuery schema") 119 | } 120 | if err := bq.UpdateTable(ctx, tableID, bigquery.TableMetadataToUpdate{ 121 | Schema: mergedSchema, 122 | }, metaData.ETag); err != nil { 123 | return nil, goerr.Wrap(err, "failed to update BigQuery table") 124 | } 125 | 126 | return mergedSchema, nil 127 | } 128 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish container image 2 | 3 | on: 4 | push: 5 | 6 | env: 7 | TAG_NAME: octovy:${{ github.sha }} 8 | GITHUB_IMAGE_REPO: ghcr.io/${{ github.repository_owner }}/octovy 9 | GITHUB_IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/octovy:${{ github.sha }} 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: checkout 16 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 17 | 18 | - name: Go Build Cache for Docker 19 | uses: actions/cache@v3 20 | with: 21 | path: go-build-cache 22 | key: ${{ runner.os }}-go-build-cache-${{ hashFiles('go.sum') }} 23 | 24 | - name: inject go-build-cache into docker 25 | # v1 was composed of two actions: "inject" and "extract". 26 | # v2 is unified to a single action. 27 | uses: reproducible-containers/buildkit-cache-dance@v2.1.2 28 | with: 29 | cache-source: go-build-cache 30 | 31 | - name: Set up Docker buildx 32 | uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 33 | - name: Login to GitHub Container Registry 34 | uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 35 | with: 36 | registry: ghcr.io 37 | username: ${{ github.repository_owner }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - name: Get the tag or commit id 41 | id: version 42 | run: | 43 | if [[ $GITHUB_REF == refs/tags/* ]]; then 44 | # If a tag is present, strip the 'refs/tags/' prefix 45 | TAG_OR_COMMIT=$(echo $GITHUB_REF | sed 's/refs\/tags\///') 46 | echo "This is a tag: $TAG_OR_COMMIT" 47 | else 48 | # If no tag is present, use the commit SHA 49 | TAG_OR_COMMIT=$(echo $GITHUB_SHA) 50 | echo "This is a commit SHA: $TAG_OR_COMMIT" 51 | fi 52 | # Set the variable for use in other steps 53 | echo "TAG_OR_COMMIT=$TAG_OR_COMMIT" >> $GITHUB_OUTPUT 54 | shell: bash 55 | 56 | - name: Build and push 57 | uses: docker/build-push-action@4a13e500e55cf31b7a5d59a38ab2040ab0f42f56 # v5.1.0 58 | with: 59 | context: . 60 | push: true 61 | tags: ${{ env.GITHUB_IMAGE_NAME }} 62 | build-args: | 63 | BUILD_VERSION=${{ steps.version.outputs.TAG_OR_COMMIT }} 64 | cache-from: type=gha 65 | cache-to: type=gha,mode=max 66 | # platforms: linux/amd64,linux/arm64 67 | 68 | release: 69 | runs-on: ubuntu-latest 70 | needs: build 71 | if: startsWith(github.ref, 'refs/tags/') 72 | steps: 73 | - name: checkout 74 | uses: actions/checkout@v2 75 | - name: extract tag 76 | id: tag 77 | run: | 78 | TAG=$(echo ${{ github.ref }} | sed -e "s#refs/tags/##g") 79 | echo ::set-output name=tag::$TAG 80 | - name: Login to GitHub Container Registry 81 | uses: docker/login-action@v1 82 | with: 83 | registry: ghcr.io 84 | username: ${{ github.repository_owner }} 85 | password: ${{ secrets.GITHUB_TOKEN }} 86 | - name: Pull Docker image 87 | run: docker pull ${{ env.GITHUB_IMAGE_NAME }} 88 | - name: Rename Docker image (tag name) 89 | run: docker tag ${{ env.GITHUB_IMAGE_NAME }} "${{ env.GITHUB_IMAGE_REPO }}:${{ steps.tag.outputs.tag }}" 90 | - name: Rename Docker image (latest) 91 | run: docker tag ${{ env.GITHUB_IMAGE_NAME }} "${{ env.GITHUB_IMAGE_REPO }}:latest" 92 | - name: Push Docker image (tag name) 93 | run: docker push "${{ env.GITHUB_IMAGE_REPO }}:${{ steps.tag.outputs.tag }}" 94 | - name: Push Docker image (latest) 95 | run: docker push "${{ env.GITHUB_IMAGE_REPO }}:latest" 96 | -------------------------------------------------------------------------------- /pkg/domain/model/trivy/report.go: -------------------------------------------------------------------------------- 1 | package trivy 2 | 3 | import ( 4 | "github.com/m-mizutani/goerr/v2" 5 | "github.com/m-mizutani/octovy/pkg/domain/types" 6 | ) 7 | 8 | type Report struct { 9 | SchemaVersion int `json:",omitempty"` 10 | ArtifactName string `json:",omitempty"` 11 | ArtifactType ArtifactType `json:",omitempty"` 12 | Metadata Metadata `json:",omitempty"` 13 | Results Results `json:",omitempty"` 14 | } 15 | 16 | // Validate checks the required fields are filled. Currently, it checks only schema version. 17 | func (x *Report) Validate() error { 18 | if x.SchemaVersion == 0 { 19 | return goerr.Wrap(types.ErrValidationFailed, "schema version is empty") 20 | } 21 | return nil 22 | } 23 | 24 | // Metadata represents a metadata of artifact 25 | type Metadata struct { 26 | Size int64 `json:",omitempty"` 27 | OS *OS `json:",omitempty"` 28 | 29 | // Container image 30 | ImageID string `json:",omitempty"` 31 | DiffIDs []string `json:",omitempty"` 32 | RepoTags []string `json:",omitempty"` 33 | RepoDigests []string `json:",omitempty"` 34 | ImageConfig ConfigFile `json:",omitempty"` 35 | } 36 | 37 | type Results []Result 38 | 39 | type Result struct { 40 | Target string `json:"Target"` 41 | Class ResultClass `json:"Class,omitempty"` 42 | Type string `json:"Type,omitempty"` 43 | Packages []Package `json:"Packages,omitempty"` 44 | Vulnerabilities []DetectedVulnerability `json:"Vulnerabilities,omitempty"` 45 | MisconfSummary *MisconfSummary `json:"MisconfSummary,omitempty"` 46 | Misconfigurations []DetectedMisconfiguration `json:"Misconfigurations,omitempty"` 47 | Secrets []SecretFinding `json:"Secrets,omitempty"` 48 | Licenses []DetectedLicense `json:"Licenses,omitempty"` 49 | // CustomResources []ftypes.CustomResource `json:"CustomResources,omitempty"` 50 | } 51 | 52 | type ResultClass string 53 | type Compliance = string 54 | type Format string 55 | type ArtifactType string 56 | type Digest string 57 | 58 | type Status int 59 | 60 | type Repository struct { 61 | Family string `json:",omitempty"` 62 | Release string `json:",omitempty"` 63 | } 64 | 65 | type Layer struct { 66 | Digest string `json:",omitempty"` 67 | DiffID string `json:",omitempty"` 68 | CreatedBy string `json:",omitempty"` 69 | } 70 | 71 | type Package struct { 72 | ID string `json:",omitempty"` 73 | Name string `json:",omitempty"` 74 | Version string `json:",omitempty"` 75 | Release string `json:",omitempty"` 76 | Epoch int `json:",omitempty"` 77 | Arch string `json:",omitempty"` 78 | Dev bool `json:",omitempty"` 79 | SrcName string `json:",omitempty"` 80 | SrcVersion string `json:",omitempty"` 81 | SrcRelease string `json:",omitempty"` 82 | SrcEpoch int `json:",omitempty"` 83 | Licenses []string `json:",omitempty"` 84 | Maintainer string `json:",omitempty"` 85 | 86 | Modularitylabel string `json:",omitempty"` // only for Red Hat based distributions 87 | BuildInfo *BuildInfo `json:",omitempty"` // only for Red Hat 88 | 89 | Ref string `json:",omitempty"` // identifier which can be used to reference the component elsewhere 90 | Indirect bool `json:",omitempty"` // this package is direct dependency of the project or not 91 | 92 | // Dependencies of this package 93 | // Note: it may have interdependencies, which may lead to infinite loops. 94 | DependsOn []string `json:",omitempty"` 95 | 96 | Layer Layer `json:",omitempty"` 97 | 98 | // Each package metadata have the file path, while the package from lock files does not have. 99 | FilePath string `json:",omitempty"` 100 | 101 | // This is required when using SPDX formats. Otherwise, it will be empty. 102 | Digest Digest `json:",omitempty"` 103 | 104 | // lines from the lock file where the dependency is written 105 | Locations []Location `json:",omitempty"` 106 | } 107 | 108 | type Location struct { 109 | StartLine int `json:",omitempty"` 110 | EndLine int `json:",omitempty"` 111 | } 112 | 113 | // BuildInfo represents information under /root/buildinfo in RHEL 114 | type BuildInfo struct { 115 | ContentSets []string `json:",omitempty"` 116 | Nvr string `json:",omitempty"` 117 | Arch string `json:",omitempty"` 118 | } 119 | -------------------------------------------------------------------------------- /pkg/domain/model/trivy/image.go: -------------------------------------------------------------------------------- 1 | package trivy 2 | 3 | import "time" 4 | 5 | type ConfigFile struct { 6 | Architecture string `json:"architecture"` 7 | Author string `json:"author,omitempty"` 8 | Container string `json:"container,omitempty"` 9 | // Created Time `json:"created,omitempty"` 10 | Created string `json:"created,omitempty"` 11 | DockerVersion string `json:"docker_version,omitempty"` 12 | History []History `json:"history,omitempty"` 13 | OS string `json:"os"` 14 | RootFS RootFS `json:"rootfs"` 15 | Config Config `json:"config"` 16 | 17 | // BigQuery does not support a field name with a dot, so we need to skip this field. 18 | // OSVersion string `json:"os.version,omitempty"` 19 | // OSFeatures []string `json:"os.features,omitempty"` 20 | 21 | Variant string `json:"variant,omitempty"` 22 | } 23 | 24 | type History struct { 25 | Author string `json:"author,omitempty"` 26 | Created string `json:"created,omitempty"` 27 | CreatedBy string `json:"created_by,omitempty"` 28 | Comment string `json:"comment,omitempty"` 29 | EmptyLayer bool `json:"empty_layer,omitempty"` 30 | } 31 | 32 | type OS struct { 33 | Family string 34 | Name string 35 | Eosl bool `json:"EOSL,omitempty"` 36 | 37 | // This field is used for enhanced security maintenance programs such as Ubuntu ESM, Debian Extended LTS. 38 | Extended bool `json:"extended,omitempty"` 39 | } 40 | 41 | type RootFS struct { 42 | Type string `json:"type"` 43 | DiffIDs []Hash `json:"diff_ids"` 44 | } 45 | 46 | type Hash struct { 47 | // Algorithm holds the algorithm used to compute the hash. 48 | Algorithm string 49 | 50 | // Hex holds the hex portion of the content hash. 51 | Hex string 52 | } 53 | 54 | type Config struct { 55 | AttachStderr bool `json:"AttachStderr,omitempty"` 56 | AttachStdin bool `json:"AttachStdin,omitempty"` 57 | AttachStdout bool `json:"AttachStdout,omitempty"` 58 | Cmd []string `json:"Cmd,omitempty"` 59 | Healthcheck *HealthConfig `json:"Healthcheck,omitempty"` 60 | Domainname string `json:"Domainname,omitempty"` 61 | Entrypoint []string `json:"Entrypoint,omitempty"` 62 | Env []string `json:"Env,omitempty"` 63 | Hostname string `json:"Hostname,omitempty"` 64 | Image string `json:"Image,omitempty"` 65 | Labels map[string]string `json:"Labels,omitempty"` 66 | OnBuild []string `json:"OnBuild,omitempty"` 67 | OpenStdin bool `json:"OpenStdin,omitempty"` 68 | StdinOnce bool `json:"StdinOnce,omitempty"` 69 | Tty bool `json:"Tty,omitempty"` 70 | User string `json:"User,omitempty"` 71 | Volumes map[string]struct{} `json:"Volumes,omitempty"` 72 | WorkingDir string `json:"WorkingDir,omitempty"` 73 | ExposedPorts map[string]struct{} `json:"ExposedPorts,omitempty"` 74 | ArgsEscaped bool `json:"ArgsEscaped,omitempty"` 75 | NetworkDisabled bool `json:"NetworkDisabled,omitempty"` 76 | MacAddress string `json:"MacAddress,omitempty"` 77 | StopSignal string `json:"StopSignal,omitempty"` 78 | Shell []string `json:"Shell,omitempty"` 79 | } 80 | 81 | type HealthConfig struct { 82 | // Test is the test to perform to check that the container is healthy. 83 | // An empty slice means to inherit the default. 84 | // The options are: 85 | // {} : inherit healthcheck 86 | // {"NONE"} : disable healthcheck 87 | // {"CMD", args...} : exec arguments directly 88 | // {"CMD-SHELL", command} : run command with system's default shell 89 | Test []string `json:",omitempty"` 90 | 91 | // Zero means to inherit. Durations are expressed as integer nanoseconds. 92 | Interval time.Duration `json:",omitempty"` // Interval is the time to wait between checks. 93 | Timeout time.Duration `json:",omitempty"` // Timeout is the time to wait before considering the check to have hung. 94 | StartPeriod time.Duration `json:",omitempty"` // The start period for the container to initialize before the retries starts to count down. 95 | 96 | // Retries is the number of consecutive failures needed to consider a container as unhealthy. 97 | // Zero means inherit. 98 | Retries int `json:",omitempty"` 99 | } 100 | -------------------------------------------------------------------------------- /pkg/controller/server/github.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/google/go-github/v53/github" 10 | "github.com/m-mizutani/goerr/v2" 11 | "github.com/m-mizutani/octovy/pkg/domain/interfaces" 12 | "github.com/m-mizutani/octovy/pkg/domain/model" 13 | "github.com/m-mizutani/octovy/pkg/domain/types" 14 | "github.com/m-mizutani/octovy/pkg/utils" 15 | ) 16 | 17 | func handleGitHubAppEvent(uc interfaces.UseCase, r *http.Request, key types.GitHubAppSecret) error { 18 | ctx := r.Context() 19 | payload, err := github.ValidatePayload(r, []byte(key)) 20 | if err != nil { 21 | return goerr.Wrap(err, "validating payload") 22 | } 23 | 24 | event, err := github.ParseWebHook(github.WebHookType(r), payload) 25 | if err != nil { 26 | return goerr.Wrap(err, "parsing webhook") 27 | } 28 | 29 | utils.CtxLogger(ctx).With(slog.Any("event", event)).Info("Received GitHub App event") 30 | 31 | scanInput := githubEventToScanInput(event) 32 | if scanInput == nil { 33 | return nil 34 | } 35 | 36 | utils.Logger().With(slog.Any("input", scanInput)).Info("Scan GitHub repository") 37 | 38 | if err := uc.ScanGitHubRepo(r.Context(), scanInput); err != nil { 39 | return goerr.Wrap(err, "failed to scan GitHub repository") 40 | } 41 | 42 | return nil 43 | } 44 | 45 | func refToBranch(v string) string { 46 | if ref := strings.SplitN(v, "/", 3); len(ref) == 3 && ref[0] == "refs" && ref[1] == "heads" { 47 | return ref[2] 48 | } 49 | return v 50 | } 51 | 52 | func githubEventToScanInput(event interface{}) *model.ScanGitHubRepoInput { 53 | switch ev := event.(type) { 54 | case *github.PushEvent: 55 | if ev.HeadCommit == nil || ev.HeadCommit.ID == nil { 56 | utils.Logger().Warn("ignore push event without head commit", slog.Any("event", ev)) 57 | return nil 58 | } 59 | 60 | return &model.ScanGitHubRepoInput{ 61 | GitHubMetadata: model.GitHubMetadata{ 62 | GitHubCommit: model.GitHubCommit{ 63 | GitHubRepo: model.GitHubRepo{ 64 | RepoID: ev.GetRepo().GetID(), 65 | Owner: ev.GetRepo().GetOwner().GetLogin(), 66 | RepoName: ev.GetRepo().GetName(), 67 | }, 68 | CommitID: ev.GetHeadCommit().GetID(), 69 | Branch: refToBranch(ev.GetRef()), 70 | Ref: ev.GetRef(), 71 | Committer: model.GitHubUser{ 72 | Login: ev.GetHeadCommit().GetCommitter().GetLogin(), 73 | Email: ev.GetHeadCommit().GetCommitter().GetEmail(), 74 | }, 75 | }, 76 | DefaultBranch: ev.GetRepo().GetDefaultBranch(), 77 | }, 78 | InstallID: types.GitHubAppInstallID(ev.GetInstallation().GetID()), 79 | } 80 | 81 | case *github.PullRequestEvent: 82 | if ev.GetAction() != "opened" && ev.GetAction() != "synchronize" { 83 | utils.Logger().Debug("ignore PR event", slog.String("action", ev.GetAction())) 84 | return nil 85 | } 86 | if ev.GetPullRequest().GetDraft() { 87 | utils.Logger().Debug("ignore draft PR", slog.String("action", ev.GetAction())) 88 | return nil 89 | } 90 | 91 | pr := ev.GetPullRequest() 92 | 93 | input := &model.ScanGitHubRepoInput{ 94 | GitHubMetadata: model.GitHubMetadata{ 95 | GitHubCommit: model.GitHubCommit{ 96 | GitHubRepo: model.GitHubRepo{ 97 | RepoID: ev.GetRepo().GetID(), 98 | Owner: ev.GetRepo().GetOwner().GetLogin(), 99 | RepoName: ev.GetRepo().GetName(), 100 | }, 101 | CommitID: pr.GetHead().GetSHA(), 102 | Ref: pr.GetHead().GetRef(), 103 | Branch: pr.GetHead().GetRef(), 104 | Committer: model.GitHubUser{ 105 | ID: pr.GetHead().GetUser().GetID(), 106 | Login: pr.GetHead().GetUser().GetLogin(), 107 | Email: pr.GetHead().GetUser().GetEmail(), 108 | }, 109 | }, 110 | DefaultBranch: ev.GetRepo().GetDefaultBranch(), 111 | PullRequest: &model.GitHubPullRequest{ 112 | ID: pr.GetID(), 113 | Number: pr.GetNumber(), 114 | BaseBranch: pr.GetBase().GetRef(), 115 | BaseCommitID: pr.GetBase().GetSHA(), 116 | User: model.GitHubUser{ 117 | ID: pr.GetBase().GetUser().GetID(), 118 | Login: pr.GetBase().GetUser().GetLogin(), 119 | Email: pr.GetBase().GetUser().GetEmail(), 120 | }, 121 | }, 122 | }, 123 | InstallID: types.GitHubAppInstallID(ev.GetInstallation().GetID()), 124 | } 125 | 126 | return input 127 | 128 | case *github.InstallationEvent, *github.InstallationRepositoriesEvent: 129 | return nil // ignore 130 | 131 | default: 132 | utils.Logger().Warn("unsupported event", slog.Any("event", fmt.Sprintf("%T", event))) 133 | return nil 134 | } 135 | } 136 | 137 | func handleGitHubActionEvent(_ interfaces.UseCase, _ *http.Request) error { 138 | return nil 139 | } 140 | -------------------------------------------------------------------------------- /pkg/usecase/comment_githug_pr.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | _ "embed" 7 | "encoding/json" 8 | "strings" 9 | "text/template" 10 | "time" 11 | 12 | "github.com/m-mizutani/goerr/v2" 13 | "github.com/m-mizutani/octovy/pkg/domain/logic" 14 | "github.com/m-mizutani/octovy/pkg/domain/model" 15 | "github.com/m-mizutani/octovy/pkg/domain/model/trivy" 16 | "github.com/m-mizutani/octovy/pkg/domain/types" 17 | ) 18 | 19 | func (x *UseCase) CommentGitHubPR(ctx context.Context, input *model.ScanGitHubRepoInput, report *trivy.Report, cfg *model.Config) error { 20 | if err := input.Validate(); err != nil { 21 | return err 22 | } 23 | 24 | if nil == input.GitHubMetadata.PullRequest { 25 | return goerr.New("PullRequest is not set") 26 | } 27 | 28 | if x.clients.GitHubApp() == nil { 29 | return goerr.New("GitHubApp client is not set") 30 | } 31 | if x.clients.Storage() == nil { 32 | return goerr.New("Storage client is not configured") 33 | } 34 | 35 | // Do not comment if there is no scan target in the repository 36 | if len(report.Results) == 0 { 37 | return nil 38 | } 39 | 40 | // Filter report by ignore targets 41 | report = logic.FilterReport(report, cfg, time.Now()) 42 | 43 | var added, fixed trivy.Results 44 | target := model.GitHubMetadata{ 45 | GitHubCommit: model.GitHubCommit{ 46 | GitHubRepo: input.GitHubMetadata.GitHubRepo, 47 | CommitID: input.GitHubMetadata.PullRequest.BaseCommitID, 48 | }, 49 | } 50 | 51 | commitKey := toStorageCommitKey(target) 52 | r, err := x.clients.Storage().Get(ctx, commitKey) 53 | if err != nil { 54 | return err 55 | } else if r != nil { 56 | defer r.Close() 57 | 58 | var oldScan model.Scan 59 | if err := json.NewDecoder(r).Decode(&oldScan); err != nil { 60 | return goerr.Wrap(err, "Failed to decode old scan result") 61 | } 62 | 63 | oldReport := logic.FilterReport(&oldScan.Report, cfg, time.Now()) 64 | fixed, added = logic.DiffResults(oldReport, report) 65 | } 66 | 67 | body, err := renderScanReport(report, added, fixed) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | if err := x.hideGitHubOldComments(ctx, input); err != nil { 73 | return err 74 | } 75 | 76 | if x.disableNoDetectionComment { 77 | var fixableVulnCount int 78 | for _, result := range report.Results { 79 | for _, vuln := range result.Vulnerabilities { 80 | if vuln.FixedVersion != "" { 81 | fixableVulnCount++ 82 | } 83 | } 84 | } 85 | if fixableVulnCount == 0 { 86 | return nil 87 | } 88 | } 89 | 90 | if err := x.clients.GitHubApp().CreateIssueComment(ctx, &input.GitHubMetadata.GitHubRepo, input.InstallID, input.PullRequest.Number, body); err != nil { 91 | return err 92 | } 93 | 94 | return nil 95 | } 96 | 97 | func (x *UseCase) hideGitHubOldComments(ctx context.Context, input *model.ScanGitHubRepoInput) error { 98 | if nil == input.GitHubMetadata.PullRequest { 99 | return goerr.New("PullRequest is not set") 100 | } 101 | 102 | if x.clients.GitHubApp() == nil { 103 | return goerr.New("GitHubApp client is not set") 104 | } 105 | 106 | comments, err := x.clients.GitHubApp().ListIssueComments(ctx, &input.GitHubMetadata.GitHubRepo, input.InstallID, input.PullRequest.Number) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | for _, comment := range comments { 112 | if !comment.IsMinimized && strings.HasPrefix(comment.Body, types.GitHubCommentSignature) { 113 | if err := x.clients.GitHubApp().MinimizeComment(ctx, &input.GitHubMetadata.GitHubRepo, input.InstallID, comment.ID); err != nil { 114 | return err 115 | } 116 | } 117 | } 118 | 119 | return nil 120 | } 121 | 122 | type scanReport struct { 123 | Signature string 124 | Metadata scanReportMetadata 125 | Report *trivy.Report 126 | Added trivy.Results 127 | Fixed trivy.Results 128 | } 129 | 130 | type scanReportMetadata struct { 131 | TotalVulnCount int 132 | FixableVulnCount int 133 | } 134 | 135 | //go:embed templates/comment_body.md 136 | var commentBodyTemplateData string 137 | 138 | var commentBodyTemplate *template.Template 139 | 140 | func init() { 141 | commentBodyTemplate = template.Must(template.New("commentBody").Parse(commentBodyTemplateData)) 142 | } 143 | 144 | func renderScanReport(report *trivy.Report, added, fixed trivy.Results) (string, error) { 145 | data := scanReport{ 146 | Signature: types.GitHubCommentSignature, 147 | Report: report, 148 | Added: added, 149 | Fixed: fixed, 150 | } 151 | 152 | for _, result := range report.Results { 153 | for _, vuln := range result.Vulnerabilities { 154 | data.Metadata.TotalVulnCount++ 155 | if vuln.FixedVersion != "" { 156 | data.Metadata.FixableVulnCount++ 157 | } 158 | } 159 | } 160 | 161 | var buf bytes.Buffer 162 | if err := commentBodyTemplate.Execute(&buf, data); err != nil { 163 | return "", goerr.Wrap(err, "failed to render comment body template") 164 | } 165 | 166 | return buf.String(), nil 167 | } 168 | -------------------------------------------------------------------------------- /pkg/controller/cli/serve/serve.go: -------------------------------------------------------------------------------- 1 | package serve 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/m-mizutani/goerr/v2" 13 | "github.com/m-mizutani/gots/slice" 14 | "github.com/m-mizutani/octovy/pkg/controller/cli/config" 15 | "github.com/m-mizutani/octovy/pkg/controller/server" 16 | "github.com/m-mizutani/octovy/pkg/infra" 17 | "github.com/m-mizutani/octovy/pkg/infra/gh" 18 | "github.com/m-mizutani/octovy/pkg/infra/trivy" 19 | "github.com/m-mizutani/octovy/pkg/usecase" 20 | "github.com/m-mizutani/octovy/pkg/utils" 21 | 22 | "github.com/urfave/cli/v2" 23 | 24 | _ "github.com/lib/pq" 25 | ) 26 | 27 | func New() *cli.Command { 28 | var ( 29 | addr string 30 | trivyPath string 31 | disableNoDetectionComment bool 32 | 33 | githubApp config.GitHubApp 34 | bigQuery config.BigQuery 35 | cloudStorage config.CloudStorage 36 | sentry config.Sentry 37 | policy config.Policy 38 | ) 39 | serveFlags := []cli.Flag{ 40 | &cli.StringFlag{ 41 | Name: "addr", 42 | Usage: "Binding address", 43 | Value: "127.0.0.1:8000", 44 | EnvVars: []string{"OCTOVY_ADDR"}, 45 | Destination: &addr, 46 | }, 47 | &cli.StringFlag{ 48 | Name: "trivy-path", 49 | Usage: "Path to trivy binary", 50 | Value: "trivy", 51 | EnvVars: []string{"OCTOVY_TRIVY_PATH"}, 52 | Destination: &trivyPath, 53 | }, 54 | &cli.BoolFlag{ 55 | Name: "disable-no-detection-comment", 56 | Usage: "Disable comment to PR if no detection", 57 | EnvVars: []string{"OCTOVY_DISABLE_NO_DETECTION_COMMENT"}, 58 | Destination: &disableNoDetectionComment, 59 | }, 60 | } 61 | 62 | return &cli.Command{ 63 | Name: "serve", 64 | Aliases: []string{"s"}, 65 | Usage: "Server mode", 66 | Flags: slice.Flatten( 67 | serveFlags, 68 | githubApp.Flags(), 69 | bigQuery.Flags(), 70 | cloudStorage.Flags(), 71 | sentry.Flags(), 72 | policy.Flags(), 73 | ), 74 | Action: func(c *cli.Context) error { 75 | utils.Logger().Info("starting serve", 76 | slog.Any("Addr", addr), 77 | slog.Any("TrivyPath", trivyPath), 78 | slog.Any("GitHubApp", githubApp), 79 | slog.Any("BigQuery", bigQuery), 80 | slog.Any("CloudStorage", cloudStorage), 81 | slog.Any("Sentry", sentry), 82 | slog.Any("Policy", policy), 83 | ) 84 | 85 | if err := sentry.Configure(); err != nil { 86 | return err 87 | } 88 | 89 | ghApp, err := gh.New(githubApp.ID, githubApp.PrivateKey(), gh.WithEnableCheckRuns(githubApp.EnableCheckRuns)) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | infraOptions := []infra.Option{ 95 | infra.WithGitHubApp(ghApp), 96 | infra.WithTrivy(trivy.New(trivyPath)), 97 | } 98 | 99 | if bqClient, err := bigQuery.NewClient(c.Context); err != nil { 100 | return err 101 | } else if bqClient != nil { 102 | infraOptions = append(infraOptions, infra.WithBigQuery(bqClient)) 103 | } 104 | 105 | if csClient, err := cloudStorage.NewClient(c.Context); err != nil { 106 | return err 107 | } else if csClient != nil { 108 | infraOptions = append(infraOptions, infra.WithStorage(csClient)) 109 | } 110 | 111 | if policyClient, err := policy.Configure(); err != nil { 112 | return err 113 | } else if policyClient != nil { 114 | infraOptions = append(infraOptions, infra.WithPolicy(policyClient)) 115 | } 116 | 117 | clients := infra.New(infraOptions...) 118 | 119 | var ucOptions []usecase.Option 120 | if disableNoDetectionComment { 121 | ucOptions = append(ucOptions, usecase.WithDisableNoDetectionComment()) 122 | } 123 | 124 | uc := usecase.New(clients, ucOptions...) 125 | s := server.New(uc, server.WithGitHubSecret(githubApp.Secret)) 126 | 127 | serverErr := make(chan error, 1) 128 | httpServer := &http.Server{ 129 | Addr: addr, 130 | Handler: s.Mux(), 131 | 132 | ReadHeaderTimeout: 10 * time.Second, 133 | ReadTimeout: 30 * time.Second, 134 | WriteTimeout: 30 * time.Second, 135 | } 136 | 137 | go func() { 138 | utils.Logger().Info("starting http server", "addr", addr) 139 | if err := httpServer.ListenAndServe(); err != http.ErrServerClosed { 140 | serverErr <- goerr.Wrap(err, "failed to listen and serve") 141 | } 142 | }() 143 | 144 | quit := make(chan os.Signal, 1) 145 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 146 | 147 | select { 148 | case err := <-serverErr: 149 | return err 150 | 151 | case sig := <-quit: 152 | utils.Logger().Info("shutting down server", "signal", sig) 153 | 154 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 155 | defer cancel() 156 | 157 | if err := httpServer.Shutdown(ctx); err != nil { 158 | return goerr.Wrap(err, "failed to shutdown server") 159 | } 160 | } 161 | 162 | return nil 163 | }, 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /pkg/domain/logic/filter_test.go: -------------------------------------------------------------------------------- 1 | package logic_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/m-mizutani/gt" 8 | "github.com/m-mizutani/octovy/pkg/domain/logic" 9 | "github.com/m-mizutani/octovy/pkg/domain/model" 10 | "github.com/m-mizutani/octovy/pkg/domain/model/trivy" 11 | ) 12 | 13 | func TestFilterResults(t *testing.T) { 14 | now := time.Now() 15 | 16 | tests := []struct { 17 | name string 18 | results trivy.Results 19 | cfg *model.Config 20 | expected trivy.Results 21 | }{ 22 | { 23 | name: "No ignore targets", 24 | results: trivy.Results{ 25 | { 26 | Target: "file1", 27 | Vulnerabilities: []trivy.DetectedVulnerability{ 28 | {VulnerabilityID: "vuln1"}, 29 | {VulnerabilityID: "vuln2"}, 30 | }, 31 | }, 32 | }, 33 | cfg: &model.Config{ 34 | IgnoreList: []model.IgnoreConfig{}, 35 | }, 36 | expected: trivy.Results{ 37 | { 38 | Target: "file1", 39 | Vulnerabilities: []trivy.DetectedVulnerability{ 40 | {VulnerabilityID: "vuln1"}, 41 | {VulnerabilityID: "vuln2"}, 42 | }, 43 | }, 44 | }, 45 | }, 46 | { 47 | name: "Ignore expired vulnerabilities", 48 | results: trivy.Results{ 49 | { 50 | Target: "file1", 51 | Vulnerabilities: []trivy.DetectedVulnerability{ 52 | {VulnerabilityID: "vuln1"}, 53 | {VulnerabilityID: "vuln2"}, 54 | }, 55 | }, 56 | }, 57 | cfg: &model.Config{ 58 | IgnoreList: []model.IgnoreConfig{ 59 | { 60 | Target: "file1", 61 | Vulns: []model.IgnoreVuln{ 62 | {ID: "vuln1", ExpiresAt: now.Add(-time.Hour)}, 63 | {ID: "vuln2", ExpiresAt: now.Add(time.Hour)}, 64 | }, 65 | }, 66 | }, 67 | }, 68 | expected: trivy.Results{ 69 | { 70 | Target: "file1", 71 | Vulnerabilities: []trivy.DetectedVulnerability{ 72 | {VulnerabilityID: "vuln1"}, 73 | }, 74 | }, 75 | }, 76 | }, 77 | { 78 | name: "Ignore non-expired vulnerabilities", 79 | results: trivy.Results{ 80 | { 81 | Target: "file1", 82 | Vulnerabilities: []trivy.DetectedVulnerability{ 83 | {VulnerabilityID: "vuln1"}, 84 | {VulnerabilityID: "vuln2"}, 85 | }, 86 | }, 87 | }, 88 | cfg: &model.Config{ 89 | IgnoreList: []model.IgnoreConfig{ 90 | { 91 | Target: "file1", 92 | Vulns: []model.IgnoreVuln{ 93 | {ID: "vuln1", ExpiresAt: now.Add(time.Hour)}, 94 | }, 95 | }, 96 | }, 97 | }, 98 | expected: trivy.Results{ 99 | { 100 | Target: "file1", 101 | Vulnerabilities: []trivy.DetectedVulnerability{ 102 | {VulnerabilityID: "vuln2"}, 103 | }, 104 | }, 105 | }, 106 | }, 107 | { 108 | name: "No vulnerabilities to ignore", 109 | results: trivy.Results{ 110 | { 111 | Target: "file2", 112 | Vulnerabilities: []trivy.DetectedVulnerability{ 113 | {VulnerabilityID: "vuln3"}, 114 | }, 115 | }, 116 | }, 117 | cfg: &model.Config{ 118 | IgnoreList: []model.IgnoreConfig{ 119 | { 120 | Target: "file1", 121 | Vulns: []model.IgnoreVuln{ 122 | {ID: "vuln1", ExpiresAt: now.Add(time.Hour)}, 123 | }, 124 | }, 125 | }, 126 | }, 127 | expected: trivy.Results{ 128 | { 129 | Target: "file2", 130 | Vulnerabilities: []trivy.DetectedVulnerability{ 131 | {VulnerabilityID: "vuln3"}, 132 | }, 133 | }, 134 | }, 135 | }, 136 | { 137 | name: "Ignore vulnerabilities for different file", 138 | results: trivy.Results{ 139 | { 140 | Target: "file2", 141 | Vulnerabilities: []trivy.DetectedVulnerability{ 142 | {VulnerabilityID: "vuln3"}, 143 | }, 144 | }, 145 | }, 146 | cfg: &model.Config{ 147 | IgnoreList: []model.IgnoreConfig{ 148 | { 149 | Target: "file1", 150 | Vulns: []model.IgnoreVuln{ 151 | {ID: "vuln1", ExpiresAt: now.Add(time.Hour)}, 152 | }, 153 | }, 154 | }, 155 | }, 156 | expected: trivy.Results{ 157 | { 158 | Target: "file2", 159 | Vulnerabilities: []trivy.DetectedVulnerability{ 160 | {VulnerabilityID: "vuln3"}, 161 | }, 162 | }, 163 | }, 164 | }, 165 | { 166 | name: "Not ignore vulnerability if expiresAt is too far in the future", 167 | results: trivy.Results{ 168 | { 169 | Target: "file2", 170 | Vulnerabilities: []trivy.DetectedVulnerability{ 171 | {VulnerabilityID: "vuln3"}, 172 | }, 173 | }, 174 | }, 175 | cfg: &model.Config{ 176 | IgnoreList: []model.IgnoreConfig{ 177 | { 178 | Target: "file2", 179 | Vulns: []model.IgnoreVuln{ 180 | {ID: "vuln1", ExpiresAt: now.AddDate(0, 0, 91)}, 181 | }, 182 | }, 183 | }, 184 | }, 185 | expected: trivy.Results{ 186 | { 187 | Target: "file2", 188 | Vulnerabilities: []trivy.DetectedVulnerability{ 189 | {VulnerabilityID: "vuln3"}, 190 | }, 191 | }, 192 | }, 193 | }, 194 | } 195 | 196 | for _, tt := range tests { 197 | t.Run(tt.name, func(t *testing.T) { 198 | actual := logic.FilterResults(tt.results, tt.cfg, now) 199 | gt.Equal(t, tt.expected, actual) 200 | }) 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Octovy 2 | 3 | Octovy is a GitHub App that scans your repository's code for potentially vulnerable dependencies. It utilizes [trivy](https://github.com/aquasecurity/trivy) to detect software vulnerabilities. When triggered by events like `push` and `pull_request` from GitHub, Octovy scans the repository for dependency vulnerabilities and performs the following actions: 4 | 5 | - Adds a comment to the pull request, summarizing the vulnerabilities found 6 | - Inserts the scan results into BigQuery 7 | 8 | ![architecture](https://github.com/m-mizutani/octovy/assets/605953/4366161f-a4ff-4abb-9766-0fb4df818cb1) 9 | 10 | Octovy adds a comment to the pull request when it detects new vulnerabilities between the head of the PR and the merge destination. 11 | 12 | comment example 13 | 14 | ## Setup 15 | 16 | ### 1. Creating a GitHub App 17 | 18 | Start by creating a GitHub App [here](https://github.com/settings/apps). You can use any name and description you like. However, ensure you set the following configurations: 19 | 20 | - **General** 21 | - **Webhook URL**: `https:///webhook/github` 22 | - **Webhook secret**: A string of your choosing (e.g. `mysecret_XOIJPOIFEA`) 23 | 24 | - **Permissions & events** 25 | - Repository Permissions 26 | - **Checks**: Set to Read & Write 27 | - **Contents**: Set to Read-only 28 | - **Metadata**: Set to Read-only 29 | - **Pull Requests**: Set to Read & Write 30 | - Subscribe to events 31 | - **Pull request** 32 | - **Push** 33 | 34 | Once you have completed the setup, make sure to take note of the following information from the **General** section for future reference: 35 | 36 | - **App ID** (e.g. `123456`) 37 | - **Private Key**: Click `Generate a private key` and download the key file (e.g. `your-app-name.2023-08-14.private-key.pem`) 38 | 39 | ### 2. Setting Up Cloud Resources 40 | 41 | - **Cloud Storage**: Create a Cloud Storage bucket dedicated to storing the scan results exclusively for Octovy's use. 42 | - **BigQuery** (Optional): Create a BigQuery dataset and table for storing the scan results. Octovy will automatically update the schema. The default table name should be `scans`. 43 | 44 | ### 3. Deploying Octovy 45 | 46 | The recommended method of deploying Octovy is via a container image, available at `ghcr.io/m-mizutani/octovy`. This image is built using GitHub Actions and published to the GitHub Container Registry. 47 | 48 | To run Octovy, set the following environment variables: 49 | 50 | #### Required Environment Variables 51 | - `OCTOVY_ADDR`: The address to bind the server to (e.g. `:8080`) 52 | - `OCTOVY_GITHUB_APP_ID`: The GitHub App ID 53 | - `OCTOVY_GITHUB_APP_PRIVATE_KEY`: The path to the private key file 54 | - `OCTOVY_GITHUB_APP_SECRET`: The secret string used to verify the webhook request from GitHub 55 | - `OCTOVY_CLOUD_STORAGE_BUCKET`: The name of the Cloud Storage bucket 56 | 57 | #### Optional Environment Variables 58 | - `OCTOVY_TRIVY_PATH`: The path to the trivy binary. If you uses the our container image, you don't need to set this variable. 59 | - `OCTOVY_CLOUD_STORAGE_PREFIX`: The prefix for the Cloud Storage object 60 | - `OCTOVY_BIGQUERY_PROJECT_ID`: The name of the BigQuery dataset 61 | - `OCTOVY_BIGQUERY_DATASET_ID`: The name of the BigQuery table 62 | - `OCTOVY_BIGQUERY_TABLE_ID`: The name of the BigQuery table 63 | - `OCTOVY_BIGQUERY_IMPERSONATE_SERVICE_ACCOUNT`: The service account to impersonate when accessing BigQuery 64 | - `OCTOVY_SENTRY_DSN`: The DSN for Sentry 65 | - `OCTOVY_SENTRY_ENV`: The environment for Sentry 66 | 67 | ## Configuration 68 | 69 | ### Ignore list 70 | 71 | The developer can ignore specific vulnerabilities by adding them to the ignore list. The config file is written in CUE. See CUE definition in [pkg/domain/model/schema/ignore.cue](pkg/domain/model/schema/ignore.cue). 72 | 73 | The config file should be placed in `.octovy` directory at the root of the repository. Octovy checks all files in the `.octovy` directory recursively and loads them. (e.g. `.octovy/ignore.cue`) 74 | 75 | The following is an example of the ignore list configuration: 76 | 77 | ```cue 78 | package octovy 79 | 80 | IgnoreList: [ 81 | { 82 | Target: "Gemfile.lock" 83 | Vulns: [ 84 | { 85 | ID: "CVE-2020-8130" 86 | ExpiresAt: "2024-08-01T00:00:00Z" 87 | Comment: "This is not used" 88 | }, 89 | ] 90 | }, 91 | ] 92 | ``` 93 | 94 | `package` name should be `octovy`. `IgnoreList` is a list of `Ignore` struct. 95 | 96 | - `Target` is the file path to ignore. That should be matched `Target` of trivy 97 | - `Vulns` is a list of `IgnoreVuln` struct. 98 | - `ID` (required): the vulnerability ID to ignore. (e.g. `CVE-2022-2202`) 99 | - `ExpiresAt` (required): The expiration date of the ignore. It should be in RFC3339 format. (e.g. `2023-08-01T00:00:00`). The date must be in 90 days and if it's over 90 days, Octovy will ignore it. 100 | - `Comment` (optional): The developer's comment 101 | 102 | 103 | ## License 104 | 105 | Octovy is licensed under the Apache License 2.0. Copyright 2023 Masayoshi Mizutani -------------------------------------------------------------------------------- /pkg/domain/mock/usecase.go: -------------------------------------------------------------------------------- 1 | // Code generated by moq; DO NOT EDIT. 2 | // github.com/matryer/moq 3 | 4 | package mock 5 | 6 | import ( 7 | "context" 8 | "github.com/m-mizutani/octovy/pkg/domain/interfaces" 9 | "github.com/m-mizutani/octovy/pkg/domain/model" 10 | "github.com/m-mizutani/octovy/pkg/domain/model/trivy" 11 | "sync" 12 | ) 13 | 14 | // Ensure, that UseCaseMock does implement interfaces.UseCase. 15 | // If this is not the case, regenerate this file with moq. 16 | var _ interfaces.UseCase = &UseCaseMock{} 17 | 18 | // UseCaseMock is a mock implementation of interfaces.UseCase. 19 | // 20 | // func TestSomethingThatUsesUseCase(t *testing.T) { 21 | // 22 | // // make and configure a mocked interfaces.UseCase 23 | // mockedUseCase := &UseCaseMock{ 24 | // InsertScanResultFunc: func(ctx context.Context, meta model.GitHubMetadata, report trivy.Report, cfg model.Config) error { 25 | // panic("mock out the InsertScanResult method") 26 | // }, 27 | // ScanGitHubRepoFunc: func(ctx context.Context, input *model.ScanGitHubRepoInput) error { 28 | // panic("mock out the ScanGitHubRepo method") 29 | // }, 30 | // } 31 | // 32 | // // use mockedUseCase in code that requires interfaces.UseCase 33 | // // and then make assertions. 34 | // 35 | // } 36 | type UseCaseMock struct { 37 | // InsertScanResultFunc mocks the InsertScanResult method. 38 | InsertScanResultFunc func(ctx context.Context, meta model.GitHubMetadata, report trivy.Report, cfg model.Config) error 39 | 40 | // ScanGitHubRepoFunc mocks the ScanGitHubRepo method. 41 | ScanGitHubRepoFunc func(ctx context.Context, input *model.ScanGitHubRepoInput) error 42 | 43 | // calls tracks calls to the methods. 44 | calls struct { 45 | // InsertScanResult holds details about calls to the InsertScanResult method. 46 | InsertScanResult []struct { 47 | // Ctx is the ctx argument value. 48 | Ctx context.Context 49 | // Meta is the meta argument value. 50 | Meta model.GitHubMetadata 51 | // Report is the report argument value. 52 | Report trivy.Report 53 | // Cfg is the cfg argument value. 54 | Cfg model.Config 55 | } 56 | // ScanGitHubRepo holds details about calls to the ScanGitHubRepo method. 57 | ScanGitHubRepo []struct { 58 | // Ctx is the ctx argument value. 59 | Ctx context.Context 60 | // Input is the input argument value. 61 | Input *model.ScanGitHubRepoInput 62 | } 63 | } 64 | lockInsertScanResult sync.RWMutex 65 | lockScanGitHubRepo sync.RWMutex 66 | } 67 | 68 | // InsertScanResult calls InsertScanResultFunc. 69 | func (mock *UseCaseMock) InsertScanResult(ctx context.Context, meta model.GitHubMetadata, report trivy.Report, cfg model.Config) error { 70 | if mock.InsertScanResultFunc == nil { 71 | panic("UseCaseMock.InsertScanResultFunc: method is nil but UseCase.InsertScanResult was just called") 72 | } 73 | callInfo := struct { 74 | Ctx context.Context 75 | Meta model.GitHubMetadata 76 | Report trivy.Report 77 | Cfg model.Config 78 | }{ 79 | Ctx: ctx, 80 | Meta: meta, 81 | Report: report, 82 | Cfg: cfg, 83 | } 84 | mock.lockInsertScanResult.Lock() 85 | mock.calls.InsertScanResult = append(mock.calls.InsertScanResult, callInfo) 86 | mock.lockInsertScanResult.Unlock() 87 | return mock.InsertScanResultFunc(ctx, meta, report, cfg) 88 | } 89 | 90 | // InsertScanResultCalls gets all the calls that were made to InsertScanResult. 91 | // Check the length with: 92 | // 93 | // len(mockedUseCase.InsertScanResultCalls()) 94 | func (mock *UseCaseMock) InsertScanResultCalls() []struct { 95 | Ctx context.Context 96 | Meta model.GitHubMetadata 97 | Report trivy.Report 98 | Cfg model.Config 99 | } { 100 | var calls []struct { 101 | Ctx context.Context 102 | Meta model.GitHubMetadata 103 | Report trivy.Report 104 | Cfg model.Config 105 | } 106 | mock.lockInsertScanResult.RLock() 107 | calls = mock.calls.InsertScanResult 108 | mock.lockInsertScanResult.RUnlock() 109 | return calls 110 | } 111 | 112 | // ScanGitHubRepo calls ScanGitHubRepoFunc. 113 | func (mock *UseCaseMock) ScanGitHubRepo(ctx context.Context, input *model.ScanGitHubRepoInput) error { 114 | if mock.ScanGitHubRepoFunc == nil { 115 | panic("UseCaseMock.ScanGitHubRepoFunc: method is nil but UseCase.ScanGitHubRepo was just called") 116 | } 117 | callInfo := struct { 118 | Ctx context.Context 119 | Input *model.ScanGitHubRepoInput 120 | }{ 121 | Ctx: ctx, 122 | Input: input, 123 | } 124 | mock.lockScanGitHubRepo.Lock() 125 | mock.calls.ScanGitHubRepo = append(mock.calls.ScanGitHubRepo, callInfo) 126 | mock.lockScanGitHubRepo.Unlock() 127 | return mock.ScanGitHubRepoFunc(ctx, input) 128 | } 129 | 130 | // ScanGitHubRepoCalls gets all the calls that were made to ScanGitHubRepo. 131 | // Check the length with: 132 | // 133 | // len(mockedUseCase.ScanGitHubRepoCalls()) 134 | func (mock *UseCaseMock) ScanGitHubRepoCalls() []struct { 135 | Ctx context.Context 136 | Input *model.ScanGitHubRepoInput 137 | } { 138 | var calls []struct { 139 | Ctx context.Context 140 | Input *model.ScanGitHubRepoInput 141 | } 142 | mock.lockScanGitHubRepo.RLock() 143 | calls = mock.calls.ScanGitHubRepo 144 | mock.lockScanGitHubRepo.RUnlock() 145 | return calls 146 | } 147 | -------------------------------------------------------------------------------- /pkg/infra/bq/testdata/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "SchemaVersion": 2, 3 | "CreatedAt": "2024-04-13T10:20:43.10296+09:00", 4 | "ArtifactName": ".", 5 | "ArtifactType": "filesystem", 6 | "Metadata": { 7 | "ImageConfig": { 8 | "architecture": "", 9 | "created": "0001-01-01T00:00:00Z", 10 | "os": "", 11 | "rootfs": { 12 | "type": "", 13 | "diff_ids": null 14 | }, 15 | "config": {} 16 | } 17 | }, 18 | "Results": [ 19 | { 20 | "Target": "Gemfile.lock", 21 | "Class": "lang-pkgs", 22 | "Type": "bundler", 23 | "Packages": [ 24 | { 25 | "ID": "octovy-test@0.1.0", 26 | "Name": "octovy-test", 27 | "Identifier": { 28 | "PURL": "pkg:gem/octovy-test@0.1.0" 29 | }, 30 | "Version": "0.1.0", 31 | "Indirect": true, 32 | "Layer": {}, 33 | "Locations": [ 34 | { 35 | "StartLine": 4, 36 | "EndLine": 4 37 | } 38 | ] 39 | }, 40 | { 41 | "ID": "rake@10.5.0", 42 | "Name": "rake", 43 | "Identifier": { 44 | "PURL": "pkg:gem/rake@10.5.0" 45 | }, 46 | "Version": "10.5.0", 47 | "Layer": {}, 48 | "Locations": [ 49 | { 50 | "StartLine": 9, 51 | "EndLine": 9 52 | } 53 | ] 54 | } 55 | ], 56 | "Vulnerabilities": [ 57 | { 58 | "VulnerabilityID": "CVE-2020-8130", 59 | "PkgID": "rake@10.5.0", 60 | "PkgName": "rake", 61 | "PkgIdentifier": { 62 | "PURL": "pkg:gem/rake@10.5.0" 63 | }, 64 | "InstalledVersion": "10.5.0", 65 | "FixedVersion": "\u003e= 12.3.3", 66 | "Status": "fixed", 67 | "Layer": {}, 68 | "SeveritySource": "ruby-advisory-db", 69 | "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2020-8130", 70 | "DataSource": { 71 | "ID": "ruby-advisory-db", 72 | "Name": "Ruby Advisory Database", 73 | "URL": "https://github.com/rubysec/ruby-advisory-db" 74 | }, 75 | "Title": "rake: OS Command Injection via egrep in Rake::FileList", 76 | "Description": "There is an OS command injection vulnerability in Ruby Rake \u003c 12.3.3 in Rake::FileList when supplying a filename that begins with the pipe character `|`.", 77 | "Severity": "HIGH", 78 | "CweIDs": [ 79 | "CWE-78" 80 | ], 81 | "VendorSeverity": { 82 | "amazon": 2, 83 | "ghsa": 2, 84 | "nvd": 2, 85 | "redhat": 2, 86 | "ruby-advisory-db": 3, 87 | "ubuntu": 2 88 | }, 89 | "CVSS": { 90 | "ghsa": { 91 | "V3Vector": "CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H", 92 | "V3Score": 6.4 93 | }, 94 | "nvd": { 95 | "V2Vector": "AV:L/AC:M/Au:N/C:C/I:C/A:C", 96 | "V3Vector": "CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H", 97 | "V2Score": 6.9, 98 | "V3Score": 6.4 99 | }, 100 | "redhat": { 101 | "V3Vector": "CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H", 102 | "V3Score": 6.4 103 | } 104 | }, 105 | "References": [ 106 | "http://lists.opensuse.org/opensuse-security-announce/2020-03/msg00041.html", 107 | "https://access.redhat.com/security/cve/CVE-2020-8130", 108 | "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-8130", 109 | "https://github.com/advisories/GHSA-jppv-gw3r-w3q8", 110 | "https://github.com/ruby/rake", 111 | "https://github.com/ruby/rake/commit/5b8f8fc41a5d7d7d6a5d767e48464c60884d3aee", 112 | "https://github.com/rubysec/ruby-advisory-db/blob/master/gems/rake/CVE-2020-8130.yml", 113 | "https://hackerone.com/reports/651518", 114 | "https://lists.debian.org/debian-lts-announce/2020/02/msg00026.html", 115 | "https://lists.fedoraproject.org/archives/list/package-announce%40lists.fedoraproject.org/message/523CLQ62VRN3VVC52KMPTROCCKY4Z36B/", 116 | "https://lists.fedoraproject.org/archives/list/package-announce%40lists.fedoraproject.org/message/VXMX4ARNX2JLRJMSH4N3J3UBMUT5CI44/", 117 | "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/523CLQ62VRN3VVC52KMPTROCCKY4Z36B", 118 | "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/VXMX4ARNX2JLRJMSH4N3J3UBMUT5CI44", 119 | "https://nvd.nist.gov/vuln/detail/CVE-2020-8130", 120 | "https://ubuntu.com/security/notices/USN-4295-1", 121 | "https://usn.ubuntu.com/4295-1", 122 | "https://usn.ubuntu.com/4295-1/", 123 | "https://www.cve.org/CVERecord?id=CVE-2020-8130" 124 | ], 125 | "PublishedDate": "2020-02-24T15:15:11.957Z", 126 | "LastModifiedDate": "2023-11-07T03:26:16.5Z" 127 | } 128 | ] 129 | } 130 | ] 131 | } 132 | -------------------------------------------------------------------------------- /pkg/infra/bq/client.go: -------------------------------------------------------------------------------- 1 | package bq 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "cloud.google.com/go/bigquery" 8 | "cloud.google.com/go/bigquery/storage/managedwriter" 9 | "cloud.google.com/go/bigquery/storage/managedwriter/adapt" 10 | "github.com/m-mizutani/goerr/v2" 11 | "github.com/m-mizutani/octovy/pkg/domain/interfaces" 12 | "github.com/m-mizutani/octovy/pkg/domain/types" 13 | "github.com/m-mizutani/octovy/pkg/utils" 14 | "google.golang.org/api/googleapi" 15 | "google.golang.org/api/option" 16 | "google.golang.org/protobuf/encoding/protojson" 17 | "google.golang.org/protobuf/proto" 18 | "google.golang.org/protobuf/reflect/protoreflect" 19 | "google.golang.org/protobuf/types/dynamicpb" 20 | ) 21 | 22 | type Client struct { 23 | bqClient *bigquery.Client 24 | mwClient *managedwriter.Client 25 | project string 26 | dataset string 27 | } 28 | 29 | var _ interfaces.BigQuery = (*Client)(nil) 30 | 31 | func New(ctx context.Context, projectID types.GoogleProjectID, datasetID types.BQDatasetID, options ...option.ClientOption) (*Client, error) { 32 | mwClient, err := managedwriter.NewClient(ctx, projectID.String(), options...) 33 | if err != nil { 34 | return nil, goerr.Wrap(err, "failed to create bigquery client", goerr.V("projectID", projectID)) 35 | } 36 | 37 | bqClient, err := bigquery.NewClient(ctx, string(projectID), options...) 38 | if err != nil { 39 | return nil, goerr.Wrap(err, "failed to create BigQuery client", goerr.V("projectID", projectID)) 40 | } 41 | 42 | return &Client{ 43 | bqClient: bqClient, 44 | mwClient: mwClient, 45 | project: projectID.String(), 46 | dataset: datasetID.String(), 47 | }, nil 48 | } 49 | 50 | // CreateTable implements interfaces.BigQuery. 51 | func (x *Client) CreateTable(ctx context.Context, table types.BQTableID, md *bigquery.TableMetadata) error { 52 | if err := x.bqClient.Dataset(x.dataset).Table(table.String()).Create(ctx, md); err != nil { 53 | return goerr.Wrap(err, "failed to create table", goerr.V("dataset", x.dataset), goerr.V("table", table)) 54 | } 55 | return nil 56 | } 57 | 58 | // GetMetadata implements interfaces.BigQuery. If the table does not exist, it returns nil. 59 | func (x *Client) GetMetadata(ctx context.Context, table types.BQTableID) (*bigquery.TableMetadata, error) { 60 | md, err := x.bqClient.Dataset(x.dataset).Table(table.String()).Metadata(ctx) 61 | if err != nil { 62 | if gErr, ok := err.(*googleapi.Error); ok && gErr.Code == 404 { 63 | return nil, nil 64 | } 65 | return nil, goerr.Wrap(err, "failed to get table metadata", goerr.V("dataset", x.dataset), goerr.V("table", table)) 66 | } 67 | 68 | return md, nil 69 | } 70 | 71 | // Insert implements interfaces.BigQuery. 72 | func (x *Client) Insert(ctx context.Context, table types.BQTableID, schema bigquery.Schema, data any) error { 73 | convertedSchema, err := adapt.BQSchemaToStorageTableSchema(schema) 74 | if err != nil { 75 | return goerr.Wrap(err, "failed to convert schema") 76 | } 77 | 78 | descriptor, err := adapt.StorageSchemaToProto2Descriptor(convertedSchema, "root") 79 | if err != nil { 80 | return goerr.Wrap(err, "failed to convert schema to descriptor") 81 | } 82 | messageDescriptor, ok := descriptor.(protoreflect.MessageDescriptor) 83 | if !ok { 84 | return goerr.Wrap(err, "adapted descriptor is not a message descriptor") 85 | } 86 | descriptorProto, err := adapt.NormalizeDescriptor(messageDescriptor) 87 | if err != nil { 88 | return goerr.Wrap(err, "failed to normalize descriptor") 89 | } 90 | 91 | message := dynamicpb.NewMessage(messageDescriptor) 92 | 93 | raw, err := json.Marshal(data) 94 | if err != nil { 95 | return goerr.Wrap(err, "failed to Marshal json message", goerr.V("v", data)) 96 | } 97 | 98 | // First, json->proto message 99 | err = protojson.Unmarshal(raw, message) 100 | if err != nil { 101 | return goerr.Wrap(err, "failed to Unmarshal json message", goerr.V("raw", string(raw))) 102 | } 103 | // Then, proto message -> bytes. 104 | b, err := proto.Marshal(message) 105 | if err != nil { 106 | return goerr.Wrap(err, "failed to Marshal proto message") 107 | } 108 | 109 | rows := [][]byte{b} 110 | 111 | ms, err := x.mwClient.NewManagedStream(ctx, 112 | managedwriter.WithDestinationTable( 113 | managedwriter.TableParentFromParts( 114 | x.project, 115 | x.dataset, 116 | table.String(), 117 | ), 118 | ), 119 | // managedwriter.WithType(managedwriter.CommittedStream), 120 | managedwriter.WithSchemaDescriptor(descriptorProto), 121 | ) 122 | if err != nil { 123 | return goerr.Wrap(err, "failed to create managed stream") 124 | } 125 | defer utils.SafeClose(ms) 126 | 127 | arResult, err := ms.AppendRows(ctx, rows) 128 | if err != nil { 129 | return goerr.Wrap(err, "failed to append rows") 130 | } 131 | 132 | if _, err := arResult.FullResponse(ctx); err != nil { 133 | return goerr.Wrap(err, "failed to get append result") 134 | } 135 | 136 | return nil 137 | } 138 | 139 | // UpdateTable implements interfaces.BigQuery. 140 | func (x *Client) UpdateTable(ctx context.Context, table types.BQTableID, md bigquery.TableMetadataToUpdate, eTag string) error { 141 | if _, err := x.bqClient.Dataset(x.dataset).Table(table.String()).Update(ctx, md, eTag); err != nil { 142 | return goerr.Wrap(err, "failed to update table", goerr.V("dataset", x.dataset), goerr.V("table", table), goerr.V("meta", md)) 143 | } 144 | 145 | return nil 146 | } 147 | -------------------------------------------------------------------------------- /pkg/usecase/comment_githug_pr_test.go: -------------------------------------------------------------------------------- 1 | package usecase_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/m-mizutani/gt" 9 | "github.com/m-mizutani/octovy/pkg/domain/mock" 10 | "github.com/m-mizutani/octovy/pkg/domain/model" 11 | "github.com/m-mizutani/octovy/pkg/domain/model/trivy" 12 | "github.com/m-mizutani/octovy/pkg/domain/types" 13 | "github.com/m-mizutani/octovy/pkg/infra" 14 | "github.com/m-mizutani/octovy/pkg/usecase" 15 | ) 16 | 17 | func TestRenderScanReport(t *testing.T) { 18 | report := trivy.Report{ 19 | Results: []trivy.Result{ 20 | { 21 | Target: "target1", 22 | Vulnerabilities: []trivy.DetectedVulnerability{ 23 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1", Vulnerability: trivy.Vulnerability{Title: "Vuln title1", Severity: "HIGH"}}, 24 | {VulnerabilityID: "CVE-0000-0002", PkgName: "pkg2", Vulnerability: trivy.Vulnerability{Title: "Vuln title2", Severity: "CRITICAL"}}, 25 | }, 26 | }, 27 | { 28 | Target: "target2", 29 | Vulnerabilities: []trivy.DetectedVulnerability{ 30 | {VulnerabilityID: "CVE-0000-0003", PkgName: "pkg4", Vulnerability: trivy.Vulnerability{Title: "Vuln title3", Severity: "CRITICAL"}}, 31 | }, 32 | }, 33 | { 34 | Target: "target3", 35 | Secrets: []trivy.SecretFinding{ 36 | { 37 | RuleID: "slack-web-hook", 38 | Category: "Slack", 39 | Severity: "HIGH", 40 | Title: "Slack Web Hook", 41 | StartLine: 14, 42 | EndLine: 15, 43 | }, 44 | }, 45 | }, 46 | }, 47 | } 48 | added := trivy.Results{ 49 | { 50 | Target: "target1", 51 | Vulnerabilities: []trivy.DetectedVulnerability{ 52 | { 53 | VulnerabilityID: "CVE-0000-0002", 54 | PkgName: "pkg2", 55 | Vulnerability: trivy.Vulnerability{ 56 | Title: "Vuln title2", 57 | Description: "Vuln description2", 58 | Severity: "CRITICAL", 59 | References: []string{ 60 | "https://example.com", 61 | "https://example.com/2", 62 | }, 63 | }, 64 | }, 65 | }, 66 | }, 67 | } 68 | fixed := trivy.Results{ 69 | { 70 | Target: "target2", 71 | Vulnerabilities: []trivy.DetectedVulnerability{ 72 | { 73 | VulnerabilityID: "CVE-0000-0003", 74 | PkgName: "pkg3", 75 | Vulnerability: trivy.Vulnerability{ 76 | Title: "Vuln title3", 77 | }, 78 | }, 79 | }, 80 | }, 81 | } 82 | 83 | body, err := usecase.RenderScanReport(&report, added, fixed) 84 | gt.NoError(t, err) 85 | gt.NoError(t, os.WriteFile("templates/test_comment_body.md", []byte(body), 0644)) 86 | } 87 | 88 | func TestIgnoreIfNoResults(t *testing.T) { 89 | report := trivy.Report{ 90 | SchemaVersion: 1, 91 | ArtifactName: "test", 92 | } 93 | 94 | csMock := mock.StorageMock{} 95 | ghMock := mock.GitHubMock{} 96 | uc := usecase.New(infra.New( 97 | infra.WithGitHubApp(&ghMock), 98 | infra.WithStorage(&csMock), 99 | )) 100 | input := &model.ScanGitHubRepoInput{ 101 | GitHubMetadata: model.GitHubMetadata{ 102 | GitHubCommit: model.GitHubCommit{ 103 | GitHubRepo: model.GitHubRepo{ 104 | Owner: "blue", 105 | RepoName: "magic", 106 | RepoID: 12345, 107 | }, 108 | Committer: model.GitHubUser{Login: "octovy-bot"}, 109 | CommitID: "9b7cea90596429d5b1243caecc15b1f79598cb85", 110 | Branch: "main", 111 | Ref: "refs/pull/123/merge", 112 | }, 113 | PullRequest: &model.GitHubPullRequest{Number: 123}, 114 | }, 115 | InstallID: 12345, 116 | } 117 | 118 | ctx := context.Background() 119 | gt.NoError(t, uc.CommentGitHubPR(ctx, input, &report, &model.Config{})) 120 | } 121 | 122 | func TestHideGitHubOldComments(t *testing.T) { 123 | mockGH := &mock.GitHubMock{} 124 | 125 | uc := usecase.New(infra.New( 126 | infra.WithGitHubApp(mockGH), 127 | )) 128 | 129 | type testCase struct { 130 | comments []*model.GitHubIssueComment 131 | subjectIDs []string 132 | } 133 | 134 | runTest := func(tc testCase) func(t *testing.T) { 135 | return func(t *testing.T) { 136 | mockGH.ListIssueCommentsFunc = func(ctx context.Context, repo *model.GitHubRepo, id types.GitHubAppInstallID, prID int) ([]*model.GitHubIssueComment, error) { 137 | return tc.comments, nil 138 | } 139 | 140 | var minimized []string 141 | mockGH.MinimizeCommentFunc = func(ctx context.Context, repo *model.GitHubRepo, id types.GitHubAppInstallID, subjectID string) error { 142 | minimized = append(minimized, subjectID) 143 | return nil 144 | } 145 | 146 | input := &model.ScanGitHubRepoInput{ 147 | GitHubMetadata: model.GitHubMetadata{ 148 | GitHubCommit: model.GitHubCommit{ 149 | GitHubRepo: model.GitHubRepo{ 150 | Owner: "blue", 151 | RepoName: "magic", 152 | }, 153 | }, 154 | PullRequest: &model.GitHubPullRequest{Number: 123}, 155 | }, 156 | InstallID: 12345, 157 | } 158 | 159 | ctx := context.Background() 160 | gt.NoError(t, uc.HideGitHubOldComments(ctx, input)) 161 | gt.V(t, minimized).Equal(tc.subjectIDs) 162 | } 163 | } 164 | 165 | t.Run("no comments", runTest(testCase{})) 166 | 167 | t.Run("no minimized comments without signature", runTest(testCase{ 168 | comments: []*model.GitHubIssueComment{ 169 | {ID: "abc", Body: "comment1", IsMinimized: false}, 170 | {ID: "edf", Body: "comment2", IsMinimized: true}, 171 | }, 172 | subjectIDs: nil, 173 | })) 174 | 175 | t.Run("minimize comments with signature", runTest(testCase{ 176 | comments: []*model.GitHubIssueComment{ 177 | {ID: "abc", Body: types.GitHubCommentSignature + "\ncomment1", IsMinimized: false}, 178 | {ID: "edf", Body: types.GitHubCommentSignature + "\ncomment2", IsMinimized: true}, 179 | }, 180 | subjectIDs: []string{"abc"}, 181 | })) 182 | } 183 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/m-mizutani/octovy 2 | 3 | go 1.24.3 4 | 5 | require ( 6 | cloud.google.com/go/bigquery v1.69.0 7 | cloud.google.com/go/storage v1.55.0 8 | cuelang.org/go v0.13.2 9 | github.com/bradleyfalzon/ghinstallation/v2 v2.16.0 10 | github.com/fatih/color v1.18.0 11 | github.com/getsentry/sentry-go v0.34.1 12 | github.com/go-chi/chi/v5 v5.2.2 13 | github.com/google/go-github/v53 v53.2.0 14 | github.com/google/uuid v1.6.0 15 | github.com/lib/pq v1.10.9 16 | github.com/m-mizutani/bqs v0.1.0 17 | github.com/m-mizutani/clog v0.0.8 18 | github.com/m-mizutani/goerr/v2 v2.0.0 19 | github.com/m-mizutani/gots v0.0.0-20230529013424-0639119b2cdd 20 | github.com/m-mizutani/gt v0.0.10 21 | github.com/m-mizutani/masq v0.1.11 22 | github.com/m-mizutani/opac v0.2.2 23 | github.com/urfave/cli/v2 v2.27.7 24 | google.golang.org/api v0.242.0 25 | google.golang.org/protobuf v1.36.6 26 | ) 27 | 28 | require ( 29 | cel.dev/expr v0.24.0 // indirect 30 | cloud.google.com/go v0.121.4 // indirect 31 | cloud.google.com/go/auth v0.16.3 // indirect 32 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 33 | cloud.google.com/go/compute/metadata v0.7.0 // indirect 34 | cloud.google.com/go/iam v1.5.2 // indirect 35 | cloud.google.com/go/monitoring v1.24.2 // indirect 36 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect 37 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect 38 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect 39 | github.com/ProtonMail/go-crypto v1.3.0 // indirect 40 | github.com/agnivade/levenshtein v1.2.1 // indirect 41 | github.com/apache/arrow/go/v15 v15.0.2 // indirect 42 | github.com/beorn7/perks v1.0.1 // indirect 43 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 44 | github.com/cloudflare/circl v1.6.1 // indirect 45 | github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect 46 | github.com/cockroachdb/apd/v3 v3.2.1 // indirect 47 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 48 | github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect 49 | github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect 50 | github.com/felixge/httpsnoop v1.0.4 // indirect 51 | github.com/go-ini/ini v1.67.0 // indirect 52 | github.com/go-jose/go-jose/v4 v4.1.1 // indirect 53 | github.com/go-logr/logr v1.4.3 // indirect 54 | github.com/go-logr/stdr v1.2.2 // indirect 55 | github.com/gobwas/glob v0.2.3 // indirect 56 | github.com/goccy/go-json v0.10.5 // indirect 57 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect 58 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 59 | github.com/google/flatbuffers v25.2.10+incompatible // indirect 60 | github.com/google/go-cmp v0.7.0 // indirect 61 | github.com/google/go-github/v72 v72.0.0 // indirect 62 | github.com/google/go-querystring v1.1.0 // indirect 63 | github.com/google/s2a-go v0.1.9 // indirect 64 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 65 | github.com/googleapis/gax-go/v2 v2.15.0 // indirect 66 | github.com/k0kubun/pp/v3 v3.5.0 // indirect 67 | github.com/klauspost/compress v1.18.0 // indirect 68 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect 69 | github.com/mattn/go-colorable v0.1.14 // indirect 70 | github.com/mattn/go-isatty v0.0.20 // indirect 71 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 72 | github.com/open-policy-agent/opa v1.6.0 // indirect 73 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 74 | github.com/pierrec/lz4/v4 v4.1.22 // indirect 75 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect 76 | github.com/prometheus/client_golang v1.22.0 // indirect 77 | github.com/prometheus/client_model v0.6.2 // indirect 78 | github.com/prometheus/common v0.65.0 // indirect 79 | github.com/prometheus/procfs v0.17.0 // indirect 80 | github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect 81 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 82 | github.com/sirupsen/logrus v1.9.3 // indirect 83 | github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect 84 | github.com/tchap/go-patricia/v2 v2.3.3 // indirect 85 | github.com/vektah/gqlparser/v2 v2.5.30 // indirect 86 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect 87 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 88 | github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect 89 | github.com/yashtewari/glob-intersection v0.2.0 // indirect 90 | github.com/zeebo/errs v1.4.0 // indirect 91 | github.com/zeebo/xxh3 v1.0.2 // indirect 92 | go.opencensus.io v0.24.0 // indirect 93 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 94 | go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect 95 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect 96 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect 97 | go.opentelemetry.io/otel v1.37.0 // indirect 98 | go.opentelemetry.io/otel/metric v1.37.0 // indirect 99 | go.opentelemetry.io/otel/sdk v1.37.0 // indirect 100 | go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect 101 | go.opentelemetry.io/otel/trace v1.37.0 // indirect 102 | go.yaml.in/yaml/v2 v2.4.2 // indirect 103 | golang.org/x/crypto v0.40.0 // indirect 104 | golang.org/x/exp v0.0.0-20250717185816-542afb5b7346 // indirect 105 | golang.org/x/mod v0.26.0 // indirect 106 | golang.org/x/net v0.42.0 // indirect 107 | golang.org/x/oauth2 v0.30.0 // indirect 108 | golang.org/x/sync v0.16.0 // indirect 109 | golang.org/x/sys v0.34.0 // indirect 110 | golang.org/x/text v0.27.0 // indirect 111 | golang.org/x/time v0.12.0 // indirect 112 | golang.org/x/tools v0.35.0 // indirect 113 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 114 | google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 // indirect 115 | google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 // indirect 116 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 // indirect 117 | google.golang.org/grpc v1.73.0 // indirect 118 | gopkg.in/yaml.v3 v3.0.1 // indirect 119 | sigs.k8s.io/yaml v1.5.0 // indirect 120 | ) 121 | -------------------------------------------------------------------------------- /pkg/controller/server/github_test.go: -------------------------------------------------------------------------------- 1 | package server_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/hmac" 7 | "crypto/sha256" 8 | _ "embed" 9 | "encoding/hex" 10 | "net/http" 11 | "net/http/httptest" 12 | "testing" 13 | 14 | "github.com/google/uuid" 15 | "github.com/m-mizutani/gt" 16 | "github.com/m-mizutani/octovy/pkg/controller/server" 17 | "github.com/m-mizutani/octovy/pkg/domain/mock" 18 | "github.com/m-mizutani/octovy/pkg/domain/model" 19 | "github.com/m-mizutani/octovy/pkg/domain/types" 20 | ) 21 | 22 | //go:embed testdata/github/pull_request.opened.json 23 | var testGitHubPullRequestOpened []byte 24 | 25 | //go:embed testdata/github/pull_request.synchronize.json 26 | var testGitHubPullRequestSynchronize []byte 27 | 28 | //go:embed testdata/github/pull_request.synchronize-draft.json 29 | var testGitHubPullRequestSynchronizeDraft []byte 30 | 31 | //go:embed testdata/github/push.json 32 | var testGitHubPush []byte 33 | 34 | //go:embed testdata/github/push.default.json 35 | var testGitHubPushDefault []byte 36 | 37 | func TestGitHubPullRequestSync(t *testing.T) { 38 | const secret = "dummy" 39 | 40 | type testCase struct { 41 | input *model.ScanGitHubRepoInput 42 | event string 43 | body []byte 44 | } 45 | 46 | runTest := func(tc testCase) func(t *testing.T) { 47 | return func(t *testing.T) { 48 | mock := &mock.UseCaseMock{ 49 | ScanGitHubRepoFunc: func(ctx context.Context, input *model.ScanGitHubRepoInput) error { 50 | gt.V(t, input).Equal(tc.input) 51 | return nil 52 | }, 53 | } 54 | 55 | serv := server.New(mock, server.WithGitHubSecret(secret)) 56 | req := newGitHubWebhookRequest(t, tc.event, tc.body, secret) 57 | w := httptest.NewRecorder() 58 | serv.Mux().ServeHTTP(w, req) 59 | gt.V(t, w.Code).Equal(http.StatusOK) 60 | if tc.input != nil { 61 | gt.A(t, mock.ScanGitHubRepoCalls()).Length(1) 62 | } else { 63 | gt.A(t, mock.ScanGitHubRepoCalls()).Length(0) 64 | } 65 | } 66 | } 67 | 68 | t.Run("pull_request.opened", runTest(testCase{ 69 | event: "pull_request", 70 | body: testGitHubPullRequestOpened, 71 | input: &model.ScanGitHubRepoInput{ 72 | GitHubMetadata: model.GitHubMetadata{ 73 | GitHubCommit: model.GitHubCommit{ 74 | GitHubRepo: model.GitHubRepo{ 75 | RepoID: 581995051, 76 | Owner: "m-mizutani", 77 | RepoName: "masq", 78 | }, 79 | Ref: "update/packages/20230918", 80 | Branch: "update/packages/20230918", 81 | CommitID: "aa0378cad00d375c1897c1b5b5a4dd125984b511", 82 | Committer: model.GitHubUser{ 83 | ID: 605953, 84 | Login: "m-mizutani", 85 | }, 86 | }, 87 | DefaultBranch: "main", 88 | PullRequest: &model.GitHubPullRequest{ 89 | ID: 1518635674, 90 | Number: 13, 91 | BaseBranch: "main", 92 | BaseCommitID: "8acdc26c9f12b9cc88e5f0b23f082f648d9e5645", 93 | User: model.GitHubUser{ 94 | ID: 605953, 95 | Login: "m-mizutani", 96 | }, 97 | }, 98 | }, 99 | InstallID: 41633205, 100 | }, 101 | })) 102 | 103 | t.Run("pull_request.synchronize", runTest(testCase{ 104 | event: "pull_request", 105 | body: testGitHubPullRequestSynchronize, 106 | input: &model.ScanGitHubRepoInput{ 107 | GitHubMetadata: model.GitHubMetadata{ 108 | GitHubCommit: model.GitHubCommit{ 109 | GitHubRepo: model.GitHubRepo{ 110 | RepoID: 359010704, 111 | Owner: "m-mizutani", 112 | RepoName: "octovy", 113 | }, 114 | Ref: "release/v0.2.0", 115 | Branch: "release/v0.2.0", 116 | CommitID: "69454c171c2f0f2dbc9ccb0c9ef9b72fd769f046", 117 | Committer: model.GitHubUser{ 118 | ID: 605953, 119 | Login: "m-mizutani", 120 | }, 121 | }, 122 | DefaultBranch: "main", 123 | PullRequest: &model.GitHubPullRequest{ 124 | ID: 1473604329, 125 | Number: 89, 126 | BaseCommitID: "08fb7816c6d0a485239ca5f342342186f972a6e7", 127 | BaseBranch: "main", 128 | User: model.GitHubUser{ 129 | ID: 605953, 130 | Login: "m-mizutani", 131 | }, 132 | }, 133 | }, 134 | InstallID: 41633205, 135 | }, 136 | })) 137 | 138 | t.Run("pull_request.synchronize: draft", runTest(testCase{ 139 | event: "pull_request", 140 | body: testGitHubPullRequestSynchronizeDraft, 141 | input: nil, 142 | })) 143 | 144 | t.Run("push", runTest(testCase{ 145 | event: "push", 146 | body: testGitHubPush, 147 | input: &model.ScanGitHubRepoInput{ 148 | GitHubMetadata: model.GitHubMetadata{ 149 | GitHubCommit: model.GitHubCommit{ 150 | GitHubRepo: model.GitHubRepo{ 151 | RepoID: 581995051, 152 | Owner: "m-mizutani", 153 | RepoName: "masq", 154 | }, 155 | CommitID: "aa0378cad00d375c1897c1b5b5a4dd125984b511", 156 | Ref: "refs/heads/update/packages/20230918", 157 | Branch: "update/packages/20230918", 158 | Committer: model.GitHubUser{ 159 | Login: "m-mizutani", 160 | Email: "mizutani@hey.com", 161 | }, 162 | }, 163 | DefaultBranch: "main", 164 | }, 165 | InstallID: 41633205, 166 | }, 167 | })) 168 | 169 | t.Run("push: to default", runTest(testCase{ 170 | event: "push", 171 | body: testGitHubPushDefault, 172 | input: &model.ScanGitHubRepoInput{ 173 | GitHubMetadata: model.GitHubMetadata{ 174 | GitHubCommit: model.GitHubCommit{ 175 | GitHubRepo: model.GitHubRepo{ 176 | RepoID: 281879096, 177 | Owner: "m-mizutani", 178 | RepoName: "ops", 179 | }, 180 | CommitID: "f58ae7668c3dfc193a1d2c0372cc52847613cde4", 181 | Ref: "refs/heads/master", 182 | Branch: "master", 183 | Committer: model.GitHubUser{ 184 | Login: "m-mizutani", 185 | Email: "mizutani@hey.com", 186 | }, 187 | }, 188 | DefaultBranch: "master", 189 | }, 190 | InstallID: 41633205, 191 | }, 192 | })) 193 | } 194 | 195 | func newGitHubWebhookRequest(t *testing.T, event string, body []byte, secret types.GitHubAppSecret) *http.Request { 196 | req := gt.R1(http.NewRequest(http.MethodPost, "/webhook/github/app", bytes.NewReader(body))).NoError(t) 197 | 198 | h := hmac.New(sha256.New, []byte(secret)) 199 | h.Write(body) 200 | 201 | req.Header.Set("X-GitHub-Event", event) 202 | req.Header.Set("X-Hub-Signature-256", "sha256="+hex.EncodeToString(h.Sum(nil))) 203 | req.Header.Set("X-GitHub-Delivery", uuid.NewString()) 204 | req.Header.Set("Content-Type", "application/json") 205 | 206 | return req 207 | } 208 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | Octovy is a GitHub App that scans repository code for vulnerable dependencies using Trivy. It responds to GitHub webhook events (`push`, `pull_request`) to scan repositories, comment on PRs with vulnerability findings, and optionally store results in BigQuery. 8 | 9 | ## Architecture 10 | 11 | The codebase follows clean architecture with clear separation of concerns: 12 | 13 | ### Layer Structure 14 | - **Controller** ([pkg/controller/](pkg/controller/)): CLI commands and HTTP server handling 15 | - `cli/`: CLI configuration and command setup using urfave/cli 16 | - `server/`: HTTP server with chi router, handles GitHub webhook events at `/webhook/github/app` and `/webhook/github/action` 17 | - **UseCase** ([pkg/usecase/](pkg/usecase/)): Business logic orchestration 18 | - `ScanGitHubRepo`: Main workflow that downloads repo, scans with Trivy, stores results, and comments on PRs 19 | - `InsertScanResult`: Exports scan results to BigQuery and Cloud Storage 20 | - `CommentGitHubPR`: Compares current vs base branch vulnerabilities and posts PR comments 21 | - **Domain** ([pkg/domain/](pkg/domain/)): Core business models and interfaces 22 | - `interfaces/`: Defines contracts for infrastructure (GitHub, BigQuery, Storage, Policy) 23 | - `model/`: Business entities (GitHub metadata, Trivy reports, Config with CUE-based ignore lists) 24 | - `logic/`: Vulnerability filtering and diff logic 25 | - `types/`: Domain-specific types and constants 26 | - **Infra** ([pkg/infra/](pkg/infra/)): External service implementations 27 | - `gh/`: GitHub API client using bradleyfalzon/ghinstallation for GitHub App authentication 28 | - `bq/`: BigQuery client for storing scan results 29 | - `cs/`: Cloud Storage client for archiving results 30 | - `trivy/`: Wrapper for Trivy CLI execution 31 | - `clients.go`: Central dependency injection container 32 | 33 | ### Key Workflows 34 | 1. **GitHub webhook received** → Server validates webhook → UseCase orchestrates scan 35 | 2. **Scan workflow**: Download repo archive → Extract to temp dir → Load `.octovy/*.cue` config → Run Trivy → Parse JSON results 36 | 3. **PR commenting**: Fetch base branch scan from Storage → Diff vulnerabilities → Post comment only for new findings 37 | 4. **Result storage**: Insert to BigQuery with auto-schema updates → Store raw JSON in Cloud Storage 38 | 39 | ### Configuration System 40 | Uses CUE language for ignore lists. Users place `.cue` files in `.octovy/` directory at repo root. Schema at [pkg/domain/model/schema/ignore.cue](pkg/domain/model/schema/ignore.cue) defines `IgnoreList` with vulnerability IDs, expiration dates (max 90 days), and comments. 41 | 42 | ### Dependency Injection 43 | The `infra.Clients` struct aggregates all infrastructure dependencies. Tests use mocks generated via `moq` (see Makefile). Interface definitions in [pkg/domain/interfaces/](pkg/domain/interfaces/) enable clean testing boundaries. 44 | 45 | ## Development Commands 46 | 47 | ### Testing 48 | ```bash 49 | # Run all tests (requires postgres service) 50 | go test --tags github ./... 51 | 52 | # Run tests for specific package 53 | go test --tags github ./pkg/usecase 54 | 55 | # Run specific test 56 | go test --tags github ./pkg/usecase -run TestScanGitHubRepo 57 | 58 | # Vet code 59 | go vet --tags github ./... 60 | ``` 61 | 62 | Note: Tests require PostgreSQL running (see [.github/workflows/test.yml](.github/workflows/test.yml) for service config). Set `TEST_DB_DSN` environment variable. 63 | 64 | ### Mock Generation 65 | ```bash 66 | # Regenerate all mocks (uses moq) 67 | make 68 | 69 | # Generate specific mock 70 | make pkg/domain/mock/infra.go 71 | ``` 72 | 73 | ### Building 74 | ```bash 75 | # Build binary 76 | go build -o octovy . 77 | 78 | # Run locally 79 | ./octovy serve --addr :8080 [other flags] 80 | ``` 81 | 82 | ### Running the Server 83 | ```bash 84 | # Via CLI 85 | octovy serve --addr :8080 86 | 87 | # Using Docker (production) 88 | docker run -p 8080:8080 -v /path/to/private-key.pem:/key.pem \ 89 | -e OCTOVY_ADDR=:8080 \ 90 | -e OCTOVY_GITHUB_APP_ID=123456 \ 91 | -e OCTOVY_GITHUB_APP_PRIVATE_KEY=/key.pem \ 92 | -e OCTOVY_GITHUB_APP_SECRET=mysecret \ 93 | -e OCTOVY_CLOUD_STORAGE_BUCKET=my-bucket \ 94 | ghcr.io/m-mizutani/octovy 95 | ``` 96 | 97 | ## Code Patterns 98 | 99 | ### Error Handling 100 | Uses `github.com/m-mizutani/goerr/v2` for wrapped errors with context. Patterns: 101 | ```go 102 | // Create error with context 103 | return goerr.New("validation failed", goerr.V("user_id", userID)) 104 | 105 | // Wrap error with context 106 | return goerr.Wrap(err, "operation failed", goerr.V("key", value)) 107 | 108 | // Multiple context values 109 | return goerr.Wrap(err, "failed to process", 110 | goerr.V("file", filename), 111 | goerr.V("line", lineNum), 112 | ) 113 | ``` 114 | 115 | Note: goerr v2 requires message as second argument to `Wrap()`. Context values are added as variadic `goerr.V()` or `goerr.Value()` arguments, not via `.With()` method chains. 116 | 117 | ### Logging 118 | Structured logging via `utils.Logger()` (log/slog) and `utils.CtxLogger(ctx)` for context-aware logging. Supports both text and JSON formats via `OCTOVY_LOG_FORMAT` environment variable. 119 | 120 | ### Testing 121 | - Uses `github.com/m-mizutani/gt` test framework for assertions 122 | - Common patterns: `gt.V(t, actual).Equal(expected)`, `gt.NoError(t, err)`, `gt.R1(fn()).NoError(t)` 123 | - Mock interfaces generated via `moq` in [pkg/domain/mock/](pkg/domain/mock/) 124 | - Test helpers in [pkg/utils/test.go](pkg/utils/test.go) 125 | - Use `--tags github` build tag for tests requiring GitHub integration 126 | 127 | ## Important Implementation Details 128 | 129 | - **Trivy integration**: Executes trivy CLI as subprocess with JSON output. Default path is `trivy`, configurable via `OCTOVY_TRIVY_PATH`. 130 | - **GitHub App authentication**: Uses installation tokens via `ghinstallation` library. Private key can be file path or PEM content. 131 | - **Temporary file handling**: Repos downloaded to temp dirs with `octovy....*` pattern. Always cleaned up with deferred `utils.SafeRemoveAll()`. 132 | - **Check Runs**: Creates GitHub Check Run at scan start, updates to "completed" with conclusion on finish (success/cancelled). 133 | - **PR comments**: Only comments when new vulnerabilities detected (diff against base branch). Can minimize old comments and optionally disable "no detection" comments via `WithDisableNoDetectionComment()`. 134 | 135 | ## Environment Variables 136 | 137 | Required for server operation (see README.md for full list): 138 | - `OCTOVY_ADDR`: Server bind address 139 | - `OCTOVY_GITHUB_APP_ID`, `OCTOVY_GITHUB_APP_PRIVATE_KEY`, `OCTOVY_GITHUB_APP_SECRET`: GitHub App credentials 140 | - `OCTOVY_CLOUD_STORAGE_BUCKET`: GCS bucket for result storage 141 | 142 | Optional BigQuery config: `OCTOVY_BIGQUERY_PROJECT_ID`, `OCTOVY_BIGQUERY_DATASET_ID`, `OCTOVY_BIGQUERY_TABLE_ID` 143 | -------------------------------------------------------------------------------- /pkg/domain/logic/diff_test.go: -------------------------------------------------------------------------------- 1 | package logic_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/m-mizutani/gt" 7 | "github.com/m-mizutani/octovy/pkg/domain/logic" 8 | "github.com/m-mizutani/octovy/pkg/domain/model/trivy" 9 | ) 10 | 11 | func TestDiffResults(t *testing.T) { 12 | type testCase struct { 13 | oldReport, newReport trivy.Report 14 | fixed, added trivy.Results 15 | } 16 | 17 | test := func(c testCase) func(t *testing.T) { 18 | return func(t *testing.T) { 19 | fixed, added := logic.DiffResults(&c.oldReport, &c.newReport) 20 | gt.Equal(t, fixed, c.fixed) 21 | gt.Equal(t, added, c.added) 22 | } 23 | } 24 | 25 | t.Run("No diff", test(testCase{ 26 | oldReport: trivy.Report{ 27 | Results: []trivy.Result{ 28 | { 29 | Target: "target1", 30 | Vulnerabilities: []trivy.DetectedVulnerability{ 31 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1"}, 32 | }, 33 | }, 34 | }, 35 | }, 36 | newReport: trivy.Report{ 37 | Results: []trivy.Result{ 38 | { 39 | Target: "target1", 40 | Vulnerabilities: []trivy.DetectedVulnerability{ 41 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1"}, 42 | }, 43 | }, 44 | }, 45 | }, 46 | fixed: nil, 47 | added: nil, 48 | })) 49 | 50 | t.Run("Add new vulnerability", test(testCase{ 51 | oldReport: trivy.Report{ 52 | Results: []trivy.Result{ 53 | { 54 | Target: "target1", 55 | Vulnerabilities: []trivy.DetectedVulnerability{ 56 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1"}, 57 | }, 58 | }, 59 | }, 60 | }, 61 | newReport: trivy.Report{ 62 | Results: []trivy.Result{ 63 | { 64 | Target: "target1", 65 | Vulnerabilities: []trivy.DetectedVulnerability{ 66 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1"}, 67 | {VulnerabilityID: "CVE-0000-0002", PkgName: "pkg2"}, 68 | }, 69 | }, 70 | }, 71 | }, 72 | fixed: nil, 73 | added: []trivy.Result{ 74 | { 75 | Target: "target1", 76 | Vulnerabilities: []trivy.DetectedVulnerability{ 77 | {VulnerabilityID: "CVE-0000-0002", PkgName: "pkg2"}, 78 | }, 79 | }, 80 | }, 81 | })) 82 | 83 | t.Run("Fix vulnerability", test(testCase{ 84 | oldReport: trivy.Report{ 85 | Results: []trivy.Result{ 86 | { 87 | Target: "target1", 88 | Vulnerabilities: []trivy.DetectedVulnerability{ 89 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1"}, 90 | {VulnerabilityID: "CVE-0000-0002", PkgName: "pkg2"}, 91 | }, 92 | }, 93 | }, 94 | }, 95 | newReport: trivy.Report{ 96 | Results: []trivy.Result{ 97 | { 98 | Target: "target1", 99 | Vulnerabilities: []trivy.DetectedVulnerability{ 100 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1"}, 101 | }, 102 | }, 103 | }, 104 | }, 105 | fixed: []trivy.Result{ 106 | { 107 | Target: "target1", 108 | Vulnerabilities: []trivy.DetectedVulnerability{ 109 | {VulnerabilityID: "CVE-0000-0002", PkgName: "pkg2"}, 110 | }, 111 | }, 112 | }, 113 | added: nil, 114 | })) 115 | 116 | t.Run("Add and fix vulnerability", test(testCase{ 117 | oldReport: trivy.Report{ 118 | Results: []trivy.Result{ 119 | { 120 | Target: "target1", 121 | Vulnerabilities: []trivy.DetectedVulnerability{ 122 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1"}, 123 | {VulnerabilityID: "CVE-0000-0002", PkgName: "pkg2"}, 124 | }, 125 | }, 126 | }, 127 | }, 128 | newReport: trivy.Report{ 129 | Results: []trivy.Result{ 130 | { 131 | Target: "target1", 132 | Vulnerabilities: []trivy.DetectedVulnerability{ 133 | 134 | {VulnerabilityID: "CVE-0000-0003", PkgName: "pkg3"}, 135 | 136 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1"}, 137 | }, 138 | }, 139 | }, 140 | }, 141 | fixed: []trivy.Result{ 142 | { 143 | Target: "target1", 144 | Vulnerabilities: []trivy.DetectedVulnerability{ 145 | {VulnerabilityID: "CVE-0000-0002", PkgName: "pkg2"}, 146 | }, 147 | }, 148 | }, 149 | added: []trivy.Result{ 150 | { 151 | Target: "target1", 152 | Vulnerabilities: []trivy.DetectedVulnerability{ 153 | {VulnerabilityID: "CVE-0000-0003", PkgName: "pkg3"}, 154 | }, 155 | }, 156 | }, 157 | })) 158 | 159 | t.Run("No diff with multiple results", test(testCase{ 160 | oldReport: trivy.Report{ 161 | Results: []trivy.Result{ 162 | { 163 | Target: "target1", 164 | Vulnerabilities: []trivy.DetectedVulnerability{ 165 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1"}, 166 | }, 167 | }, 168 | { 169 | Target: "target2", 170 | Vulnerabilities: []trivy.DetectedVulnerability{ 171 | {VulnerabilityID: "CVE-0000-0002", PkgName: "pkg2"}, 172 | }, 173 | }, 174 | }, 175 | }, 176 | newReport: trivy.Report{ 177 | Results: []trivy.Result{ 178 | { 179 | Target: "target1", 180 | Vulnerabilities: []trivy.DetectedVulnerability{ 181 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1"}, 182 | }, 183 | }, 184 | { 185 | Target: "target2", 186 | Vulnerabilities: []trivy.DetectedVulnerability{ 187 | {VulnerabilityID: "CVE-0000-0002", PkgName: "pkg2"}, 188 | }, 189 | }, 190 | }, 191 | }, 192 | fixed: nil, 193 | added: nil, 194 | })) 195 | 196 | t.Run("Add new vulnerability with multiple results", test(testCase{ 197 | oldReport: trivy.Report{ 198 | Results: []trivy.Result{ 199 | { 200 | Target: "target1", 201 | Vulnerabilities: []trivy.DetectedVulnerability{ 202 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1"}, 203 | }, 204 | }, 205 | { 206 | Target: "target2", 207 | Vulnerabilities: []trivy.DetectedVulnerability{ 208 | {VulnerabilityID: "CVE-0000-0002", PkgName: "pkg2"}, 209 | }, 210 | }, 211 | }, 212 | }, 213 | newReport: trivy.Report{ 214 | Results: []trivy.Result{ 215 | { 216 | Target: "target1", 217 | Vulnerabilities: []trivy.DetectedVulnerability{ 218 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1"}, 219 | {VulnerabilityID: "CVE-0000-0002", PkgName: "pkg2"}, 220 | }, 221 | }, 222 | { 223 | Target: "target2", 224 | Vulnerabilities: []trivy.DetectedVulnerability{ 225 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1"}, 226 | {VulnerabilityID: "CVE-0000-0002", PkgName: "pkg2"}, 227 | }, 228 | }, 229 | }, 230 | }, 231 | fixed: nil, 232 | added: []trivy.Result{ 233 | { 234 | Target: "target1", 235 | Vulnerabilities: []trivy.DetectedVulnerability{ 236 | {VulnerabilityID: "CVE-0000-0002", PkgName: "pkg2"}, 237 | }, 238 | }, 239 | { 240 | Target: "target2", 241 | Vulnerabilities: []trivy.DetectedVulnerability{ 242 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1"}, 243 | }, 244 | }, 245 | }, 246 | })) 247 | 248 | t.Run("Fix vulnerability with multiple results", test(testCase{ 249 | oldReport: trivy.Report{ 250 | Results: []trivy.Result{ 251 | { 252 | Target: "target1", 253 | Vulnerabilities: []trivy.DetectedVulnerability{ 254 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1"}, 255 | {VulnerabilityID: "CVE-0000-0002", PkgName: "pkg2"}, 256 | }, 257 | }, 258 | { 259 | Target: "target2", 260 | Vulnerabilities: []trivy.DetectedVulnerability{ 261 | {VulnerabilityID: "CVE-0000-0003", PkgName: "pkg3"}, 262 | }, 263 | }, 264 | }, 265 | }, 266 | newReport: trivy.Report{ 267 | Results: []trivy.Result{ 268 | { 269 | Target: "target1", 270 | Vulnerabilities: []trivy.DetectedVulnerability{ 271 | {VulnerabilityID: "CVE-0000-0001", PkgName: "pkg1"}, 272 | }, 273 | }, 274 | { 275 | Target: "target2", 276 | Vulnerabilities: []trivy.DetectedVulnerability{ 277 | {VulnerabilityID: "CVE-0000-0003", PkgName: "pkg3"}, 278 | }, 279 | }, 280 | }, 281 | }, 282 | fixed: []trivy.Result{ 283 | { 284 | Target: "target1", 285 | Vulnerabilities: []trivy.DetectedVulnerability{ 286 | {VulnerabilityID: "CVE-0000-0002", PkgName: "pkg2"}, 287 | }, 288 | }, 289 | }, 290 | added: nil, 291 | })) 292 | } 293 | -------------------------------------------------------------------------------- /pkg/usecase/scan_github_repo.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "archive/zip" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | "path/filepath" 14 | "strings" 15 | 16 | "github.com/google/go-github/v53/github" 17 | "github.com/m-mizutani/goerr/v2" 18 | "github.com/m-mizutani/octovy/pkg/domain/interfaces" 19 | "github.com/m-mizutani/octovy/pkg/domain/model" 20 | "github.com/m-mizutani/octovy/pkg/domain/model/trivy" 21 | "github.com/m-mizutani/octovy/pkg/domain/types" 22 | "github.com/m-mizutani/octovy/pkg/infra" 23 | "github.com/m-mizutani/octovy/pkg/utils" 24 | ) 25 | 26 | // ScanGitHubRepo is a usecase to download a source code from GitHub and scan it with Trivy. Using GitHub App credentials to download a private repository, then the app should be installed to the repository and have read access. 27 | // After scanning, the result is stored to the database. The temporary files are removed after the scan. 28 | func (x *UseCase) ScanGitHubRepo(ctx context.Context, input *model.ScanGitHubRepoInput) error { 29 | if err := input.Validate(); err != nil { 30 | return err 31 | } 32 | 33 | // Create and finalize GitHub check 34 | conclusion := "cancelled" 35 | checkID, err := x.clients.GitHubApp().CreateCheckRun(ctx, input.InstallID, &input.GitHubRepo, input.CommitID) 36 | if err != nil { 37 | return err 38 | } 39 | defer func() { 40 | opt := &github.UpdateCheckRunOptions{ 41 | Status: github.String("completed"), 42 | Conclusion: &conclusion, 43 | } 44 | if err := x.clients.GitHubApp().UpdateCheckRun(ctx, input.InstallID, &input.GitHubRepo, checkID, opt); err != nil { 45 | utils.CtxLogger(ctx).Error("Failed to update check run", "err", err) 46 | } 47 | }() 48 | 49 | // Extract zip file to local temp directory 50 | tmpDir, err := os.MkdirTemp("", fmt.Sprintf("octovy.%s.%s.%s.*", input.Owner, input.RepoName, input.CommitID)) 51 | if err != nil { 52 | return goerr.Wrap(err, "failed to create temp directory for zip file") 53 | } 54 | defer utils.SafeRemoveAll(tmpDir) 55 | 56 | if err := x.downloadGitHubRepo(ctx, input, tmpDir); err != nil { 57 | return err 58 | } 59 | 60 | cfg, err := model.LoadConfigsFromDir(filepath.Join(tmpDir, ".octovy")) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | report, err := x.scanGitHubRepo(ctx, tmpDir) 66 | if err != nil { 67 | return err 68 | } 69 | utils.CtxLogger(ctx).Info("scan finished", "input", input, "report", report) 70 | 71 | if err := x.InsertScanResult(ctx, input.GitHubMetadata, *report, *cfg); err != nil { 72 | return err 73 | } 74 | 75 | if nil != x.clients.Storage() && nil != input.GitHubMetadata.PullRequest { 76 | if err := x.CommentGitHubPR(ctx, input, report, cfg); err != nil { 77 | return err 78 | } 79 | } 80 | 81 | conclusion = "success" 82 | 83 | return nil 84 | } 85 | 86 | func (x *UseCase) downloadGitHubRepo(ctx context.Context, input *model.ScanGitHubRepoInput, dstDir string) error { 87 | zipURL, err := x.clients.GitHubApp().GetArchiveURL(ctx, &interfaces.GetArchiveURLInput{ 88 | Owner: input.Owner, 89 | Repo: input.RepoName, 90 | CommitID: input.CommitID, 91 | InstallID: input.InstallID, 92 | }) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | // Download zip file 98 | tmpZip, err := os.CreateTemp("", fmt.Sprintf("octovy_code.%s.%s.%s.*.zip", 99 | input.Owner, input.RepoName, input.CommitID, 100 | )) 101 | if err != nil { 102 | return goerr.Wrap(err, "failed to create temp file for zip file") 103 | } 104 | defer utils.SafeRemove(tmpZip.Name()) 105 | 106 | if err := downloadZipFile(ctx, x.clients.HTTPClient(), zipURL, tmpZip); err != nil { 107 | return err 108 | } 109 | if err := tmpZip.Close(); err != nil { 110 | return goerr.Wrap(err, "failed to close temp file for zip file") 111 | } 112 | 113 | if err := extractZipFile(ctx, tmpZip.Name(), dstDir); err != nil { 114 | return err 115 | } 116 | 117 | return nil 118 | } 119 | 120 | func (x *UseCase) scanGitHubRepo(ctx context.Context, codeDir string) (*trivy.Report, error) { 121 | // Scan local directory 122 | tmpResult, err := os.CreateTemp("", "octovy_result.*.json") 123 | if err != nil { 124 | return nil, goerr.Wrap(err, "failed to create temp file for scan result") 125 | } 126 | defer utils.SafeRemove(tmpResult.Name()) 127 | 128 | if err := tmpResult.Close(); err != nil { 129 | return nil, goerr.Wrap(err, "failed to close temp file for scan result") 130 | } 131 | 132 | if err := x.clients.Trivy().Run(ctx, []string{ 133 | "fs", 134 | "--exit-code", "0", 135 | "--no-progress", 136 | "--format", "json", 137 | "--output", tmpResult.Name(), 138 | "--list-all-pkgs", 139 | codeDir, 140 | }); err != nil { 141 | return nil, goerr.Wrap(err, "failed to scan local directory") 142 | } 143 | 144 | var report trivy.Report 145 | if err := unmarshalFile(tmpResult.Name(), &report); err != nil { 146 | return nil, err 147 | } 148 | 149 | utils.CtxLogger(ctx).Info("Scan result", slog.Any("report", tmpResult.Name())) 150 | 151 | return &report, nil 152 | } 153 | 154 | func unmarshalFile(path string, v any) error { 155 | fd, err := os.Open(filepath.Clean(path)) 156 | if err != nil { 157 | return goerr.Wrap(err, "failed to open file", goerr.V("path", path)) 158 | } 159 | defer utils.SafeClose(fd) 160 | 161 | if err := json.NewDecoder(fd).Decode(v); err != nil { 162 | return goerr.Wrap(err, "failed to decode json", goerr.V("path", path)) 163 | } 164 | 165 | return nil 166 | } 167 | 168 | func downloadZipFile(ctx context.Context, httpClient infra.HTTPClient, zipURL *url.URL, w io.Writer) error { 169 | zipReq, err := http.NewRequestWithContext(ctx, http.MethodGet, zipURL.String(), nil) 170 | if err != nil { 171 | return goerr.Wrap(err, "failed to create request for zip file", goerr.V("url", zipURL)) 172 | } 173 | 174 | zipResp, err := httpClient.Do(zipReq) 175 | if err != nil { 176 | return goerr.Wrap(err, "failed to download zip file", goerr.V("url", zipURL)) 177 | } 178 | defer zipResp.Body.Close() 179 | 180 | if zipResp.StatusCode != http.StatusOK { 181 | body, _ := io.ReadAll(zipResp.Body) 182 | return goerr.Wrap(types.ErrInvalidGitHubData, "failed to download zip file", 183 | goerr.V("url", zipURL), 184 | goerr.V("resp", zipResp), 185 | goerr.V("body", body), 186 | ) 187 | } 188 | 189 | if _, err = io.Copy(w, zipResp.Body); err != nil { 190 | return goerr.Wrap(err, "failed to write zip file", 191 | goerr.V("url", zipURL), 192 | goerr.V("resp", zipResp), 193 | ) 194 | } 195 | 196 | return nil 197 | } 198 | 199 | func extractZipFile(ctx context.Context, src, dst string) error { 200 | zipFile, err := zip.OpenReader(src) 201 | if err != nil { 202 | return goerr.Wrap(err, "failed to open zip file", goerr.V("file", src)) 203 | } 204 | defer utils.SafeClose(zipFile) 205 | 206 | // Extract a source code zip file 207 | for _, f := range zipFile.File { 208 | if err := extractCode(ctx, f, dst); err != nil { 209 | return err 210 | } 211 | } 212 | 213 | return nil 214 | } 215 | 216 | func extractCode(_ context.Context, f *zip.File, dst string) error { 217 | if f.FileInfo().IsDir() { 218 | return nil 219 | } 220 | 221 | target := stepDownDirectory(f.Name) 222 | if target == "" { 223 | return nil 224 | } 225 | 226 | fpath := filepath.Join(dst, target) 227 | if !strings.HasPrefix(fpath, filepath.Clean(dst)+string(os.PathSeparator)) { 228 | return goerr.Wrap(types.ErrInvalidGitHubData, "illegal file path of zip", goerr.V("path", fpath)) 229 | } 230 | 231 | if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil { 232 | return goerr.Wrap(err, "failed to create directory", goerr.V("path", fpath)) 233 | } 234 | 235 | // #nosec 236 | out, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) 237 | if err != nil { 238 | return goerr.Wrap(err, "failed to open file", goerr.V("fpath", fpath)) 239 | } 240 | defer utils.SafeClose(out) 241 | 242 | rc, err := f.Open() 243 | if err != nil { 244 | return goerr.Wrap(err, "failed to open zip entry") 245 | } 246 | defer utils.SafeClose(rc) 247 | 248 | // #nosec 249 | _, err = io.Copy(out, rc) 250 | if err != nil { 251 | return goerr.Wrap(err, "failed to copy file content") 252 | } 253 | 254 | return nil 255 | } 256 | 257 | func stepDownDirectory(fpath string) string { 258 | if len(fpath) > 0 && fpath[0] == filepath.Separator { 259 | fpath = fpath[1:] 260 | } 261 | 262 | p := fpath 263 | var arr []string 264 | for { 265 | d, f := filepath.Split(p) 266 | if d == "" { 267 | break 268 | } 269 | arr = append([]string{f}, arr...) 270 | p = filepath.Clean(d) 271 | } 272 | 273 | return filepath.Join(arr...) 274 | } 275 | -------------------------------------------------------------------------------- /pkg/controller/server/testdata/github/push.default.json: -------------------------------------------------------------------------------- 1 | { 2 | "ref": "refs/heads/master", 3 | "before": "987e1005c2e3c79631b620c4a76afd4b8111b7b1", 4 | "after": "f58ae7668c3dfc193a1d2c0372cc52847613cde4", 5 | "repository": { 6 | "id": 281879096, 7 | "node_id": "MDEwOlJlcG9zaXRvcnkyODE4NzkwOTY=", 8 | "name": "ops", 9 | "full_name": "m-mizutani/ops", 10 | "private": true, 11 | "owner": { 12 | "name": "m-mizutani", 13 | "email": "mizutani@hey.com", 14 | "login": "m-mizutani", 15 | "id": 605953, 16 | "node_id": "MDQ6VXNlcjYwNTk1Mw==", 17 | "avatar_url": "https://avatars.githubusercontent.com/u/605953?v=4", 18 | "gravatar_id": "", 19 | "url": "https://api.github.com/users/m-mizutani", 20 | "html_url": "https://github.com/m-mizutani", 21 | "followers_url": "https://api.github.com/users/m-mizutani/followers", 22 | "following_url": "https://api.github.com/users/m-mizutani/following{/other_user}", 23 | "gists_url": "https://api.github.com/users/m-mizutani/gists{/gist_id}", 24 | "starred_url": "https://api.github.com/users/m-mizutani/starred{/owner}{/repo}", 25 | "subscriptions_url": "https://api.github.com/users/m-mizutani/subscriptions", 26 | "organizations_url": "https://api.github.com/users/m-mizutani/orgs", 27 | "repos_url": "https://api.github.com/users/m-mizutani/repos", 28 | "events_url": "https://api.github.com/users/m-mizutani/events{/privacy}", 29 | "received_events_url": "https://api.github.com/users/m-mizutani/received_events", 30 | "type": "User", 31 | "site_admin": false 32 | }, 33 | "html_url": "https://github.com/m-mizutani/ops", 34 | "description": null, 35 | "fork": false, 36 | "url": "https://github.com/m-mizutani/ops", 37 | "forks_url": "https://api.github.com/repos/m-mizutani/ops/forks", 38 | "keys_url": "https://api.github.com/repos/m-mizutani/ops/keys{/key_id}", 39 | "collaborators_url": "https://api.github.com/repos/m-mizutani/ops/collaborators{/collaborator}", 40 | "teams_url": "https://api.github.com/repos/m-mizutani/ops/teams", 41 | "hooks_url": "https://api.github.com/repos/m-mizutani/ops/hooks", 42 | "issue_events_url": "https://api.github.com/repos/m-mizutani/ops/issues/events{/number}", 43 | "events_url": "https://api.github.com/repos/m-mizutani/ops/events", 44 | "assignees_url": "https://api.github.com/repos/m-mizutani/ops/assignees{/user}", 45 | "branches_url": "https://api.github.com/repos/m-mizutani/ops/branches{/branch}", 46 | "tags_url": "https://api.github.com/repos/m-mizutani/ops/tags", 47 | "blobs_url": "https://api.github.com/repos/m-mizutani/ops/git/blobs{/sha}", 48 | "git_tags_url": "https://api.github.com/repos/m-mizutani/ops/git/tags{/sha}", 49 | "git_refs_url": "https://api.github.com/repos/m-mizutani/ops/git/refs{/sha}", 50 | "trees_url": "https://api.github.com/repos/m-mizutani/ops/git/trees{/sha}", 51 | "statuses_url": "https://api.github.com/repos/m-mizutani/ops/statuses/{sha}", 52 | "languages_url": "https://api.github.com/repos/m-mizutani/ops/languages", 53 | "stargazers_url": "https://api.github.com/repos/m-mizutani/ops/stargazers", 54 | "contributors_url": "https://api.github.com/repos/m-mizutani/ops/contributors", 55 | "subscribers_url": "https://api.github.com/repos/m-mizutani/ops/subscribers", 56 | "subscription_url": "https://api.github.com/repos/m-mizutani/ops/subscription", 57 | "commits_url": "https://api.github.com/repos/m-mizutani/ops/commits{/sha}", 58 | "git_commits_url": "https://api.github.com/repos/m-mizutani/ops/git/commits{/sha}", 59 | "comments_url": "https://api.github.com/repos/m-mizutani/ops/comments{/number}", 60 | "issue_comment_url": "https://api.github.com/repos/m-mizutani/ops/issues/comments{/number}", 61 | "contents_url": "https://api.github.com/repos/m-mizutani/ops/contents/{+path}", 62 | "compare_url": "https://api.github.com/repos/m-mizutani/ops/compare/{base}...{head}", 63 | "merges_url": "https://api.github.com/repos/m-mizutani/ops/merges", 64 | "archive_url": "https://api.github.com/repos/m-mizutani/ops/{archive_format}{/ref}", 65 | "downloads_url": "https://api.github.com/repos/m-mizutani/ops/downloads", 66 | "issues_url": "https://api.github.com/repos/m-mizutani/ops/issues{/number}", 67 | "pulls_url": "https://api.github.com/repos/m-mizutani/ops/pulls{/number}", 68 | "milestones_url": "https://api.github.com/repos/m-mizutani/ops/milestones{/number}", 69 | "notifications_url": "https://api.github.com/repos/m-mizutani/ops/notifications{?since,all,participating}", 70 | "labels_url": "https://api.github.com/repos/m-mizutani/ops/labels{/name}", 71 | "releases_url": "https://api.github.com/repos/m-mizutani/ops/releases{/id}", 72 | "deployments_url": "https://api.github.com/repos/m-mizutani/ops/deployments", 73 | "created_at": 1595488464, 74 | "updated_at": "2022-10-23T01:52:43Z", 75 | "pushed_at": 1694834911, 76 | "git_url": "git://github.com/m-mizutani/ops.git", 77 | "ssh_url": "git@github.com:m-mizutani/ops.git", 78 | "clone_url": "https://github.com/m-mizutani/ops.git", 79 | "svn_url": "https://github.com/m-mizutani/ops", 80 | "homepage": null, 81 | "size": 495, 82 | "stargazers_count": 0, 83 | "watchers_count": 0, 84 | "language": "HCL", 85 | "has_issues": true, 86 | "has_projects": true, 87 | "has_downloads": true, 88 | "has_wiki": true, 89 | "has_pages": false, 90 | "has_discussions": false, 91 | "forks_count": 0, 92 | "mirror_url": null, 93 | "archived": false, 94 | "disabled": false, 95 | "open_issues_count": 0, 96 | "license": null, 97 | "allow_forking": true, 98 | "is_template": false, 99 | "web_commit_signoff_required": false, 100 | "topics": [], 101 | "visibility": "private", 102 | "forks": 0, 103 | "open_issues": 0, 104 | "watchers": 0, 105 | "default_branch": "master", 106 | "stargazers": 0, 107 | "master_branch": "master" 108 | }, 109 | "pusher": { 110 | "name": "m-mizutani", 111 | "email": "mizutani@hey.com" 112 | }, 113 | "sender": { 114 | "login": "m-mizutani", 115 | "id": 605953, 116 | "node_id": "MDQ6VXNlcjYwNTk1Mw==", 117 | "avatar_url": "https://avatars.githubusercontent.com/u/605953?v=4", 118 | "gravatar_id": "", 119 | "url": "https://api.github.com/users/m-mizutani", 120 | "html_url": "https://github.com/m-mizutani", 121 | "followers_url": "https://api.github.com/users/m-mizutani/followers", 122 | "following_url": "https://api.github.com/users/m-mizutani/following{/other_user}", 123 | "gists_url": "https://api.github.com/users/m-mizutani/gists{/gist_id}", 124 | "starred_url": "https://api.github.com/users/m-mizutani/starred{/owner}{/repo}", 125 | "subscriptions_url": "https://api.github.com/users/m-mizutani/subscriptions", 126 | "organizations_url": "https://api.github.com/users/m-mizutani/orgs", 127 | "repos_url": "https://api.github.com/users/m-mizutani/repos", 128 | "events_url": "https://api.github.com/users/m-mizutani/events{/privacy}", 129 | "received_events_url": "https://api.github.com/users/m-mizutani/received_events", 130 | "type": "User", 131 | "site_admin": false 132 | }, 133 | "installation": { 134 | "id": 41633205, 135 | "node_id": "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uNDE2MzMyMDU=" 136 | }, 137 | "created": false, 138 | "deleted": false, 139 | "forced": false, 140 | "base_ref": null, 141 | "compare": "https://github.com/m-mizutani/ops/compare/987e1005c2e3...f58ae7668c3d", 142 | "commits": [ 143 | { 144 | "id": "f58ae7668c3dfc193a1d2c0372cc52847613cde4", 145 | "tree_id": "6101b6784aec5d25a35cea43f6c6a85affe84e68", 146 | "distinct": true, 147 | "message": "empty commit", 148 | "timestamp": "2023-09-16T12:28:28+09:00", 149 | "url": "https://github.com/m-mizutani/ops/commit/f58ae7668c3dfc193a1d2c0372cc52847613cde4", 150 | "author": { 151 | "name": "Masayoshi Mizutani", 152 | "email": "mizutani@hey.com", 153 | "username": "m-mizutani" 154 | }, 155 | "committer": { 156 | "name": "Masayoshi Mizutani", 157 | "email": "mizutani@hey.com", 158 | "username": "m-mizutani" 159 | }, 160 | "added": [], 161 | "removed": [], 162 | "modified": ["README.md"] 163 | } 164 | ], 165 | "head_commit": { 166 | "id": "f58ae7668c3dfc193a1d2c0372cc52847613cde4", 167 | "tree_id": "6101b6784aec5d25a35cea43f6c6a85affe84e68", 168 | "distinct": true, 169 | "message": "empty commit", 170 | "timestamp": "2023-09-16T12:28:28+09:00", 171 | "url": "https://github.com/m-mizutani/ops/commit/f58ae7668c3dfc193a1d2c0372cc52847613cde4", 172 | "author": { 173 | "name": "Masayoshi Mizutani", 174 | "email": "mizutani@hey.com", 175 | "username": "m-mizutani" 176 | }, 177 | "committer": { 178 | "name": "Masayoshi Mizutani", 179 | "email": "mizutani@hey.com", 180 | "username": "m-mizutani" 181 | }, 182 | "added": [], 183 | "removed": [], 184 | "modified": ["README.md"] 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /pkg/controller/server/testdata/github/push.json: -------------------------------------------------------------------------------- 1 | { 2 | "ref": "refs/heads/update/packages/20230918", 3 | "before": "0000000000000000000000000000000000000000", 4 | "after": "aa0378cad00d375c1897c1b5b5a4dd125984b511", 5 | "repository": { 6 | "id": 581995051, 7 | "node_id": "R_kgDOIrCKKw", 8 | "name": "masq", 9 | "full_name": "m-mizutani/masq", 10 | "private": false, 11 | "owner": { 12 | "name": "m-mizutani", 13 | "email": "mizutani@hey.com", 14 | "login": "m-mizutani", 15 | "id": 605953, 16 | "node_id": "MDQ6VXNlcjYwNTk1Mw==", 17 | "avatar_url": "https://avatars.githubusercontent.com/u/605953?v=4", 18 | "gravatar_id": "", 19 | "url": "https://api.github.com/users/m-mizutani", 20 | "html_url": "https://github.com/m-mizutani", 21 | "followers_url": "https://api.github.com/users/m-mizutani/followers", 22 | "following_url": "https://api.github.com/users/m-mizutani/following{/other_user}", 23 | "gists_url": "https://api.github.com/users/m-mizutani/gists{/gist_id}", 24 | "starred_url": "https://api.github.com/users/m-mizutani/starred{/owner}{/repo}", 25 | "subscriptions_url": "https://api.github.com/users/m-mizutani/subscriptions", 26 | "organizations_url": "https://api.github.com/users/m-mizutani/orgs", 27 | "repos_url": "https://api.github.com/users/m-mizutani/repos", 28 | "events_url": "https://api.github.com/users/m-mizutani/events{/privacy}", 29 | "received_events_url": "https://api.github.com/users/m-mizutani/received_events", 30 | "type": "User", 31 | "site_admin": false 32 | }, 33 | "html_url": "https://github.com/m-mizutani/masq", 34 | "description": "A utility to redact sensitive data for slog in Go", 35 | "fork": false, 36 | "url": "https://github.com/m-mizutani/masq", 37 | "forks_url": "https://api.github.com/repos/m-mizutani/masq/forks", 38 | "keys_url": "https://api.github.com/repos/m-mizutani/masq/keys{/key_id}", 39 | "collaborators_url": "https://api.github.com/repos/m-mizutani/masq/collaborators{/collaborator}", 40 | "teams_url": "https://api.github.com/repos/m-mizutani/masq/teams", 41 | "hooks_url": "https://api.github.com/repos/m-mizutani/masq/hooks", 42 | "issue_events_url": "https://api.github.com/repos/m-mizutani/masq/issues/events{/number}", 43 | "events_url": "https://api.github.com/repos/m-mizutani/masq/events", 44 | "assignees_url": "https://api.github.com/repos/m-mizutani/masq/assignees{/user}", 45 | "branches_url": "https://api.github.com/repos/m-mizutani/masq/branches{/branch}", 46 | "tags_url": "https://api.github.com/repos/m-mizutani/masq/tags", 47 | "blobs_url": "https://api.github.com/repos/m-mizutani/masq/git/blobs{/sha}", 48 | "git_tags_url": "https://api.github.com/repos/m-mizutani/masq/git/tags{/sha}", 49 | "git_refs_url": "https://api.github.com/repos/m-mizutani/masq/git/refs{/sha}", 50 | "trees_url": "https://api.github.com/repos/m-mizutani/masq/git/trees{/sha}", 51 | "statuses_url": "https://api.github.com/repos/m-mizutani/masq/statuses/{sha}", 52 | "languages_url": "https://api.github.com/repos/m-mizutani/masq/languages", 53 | "stargazers_url": "https://api.github.com/repos/m-mizutani/masq/stargazers", 54 | "contributors_url": "https://api.github.com/repos/m-mizutani/masq/contributors", 55 | "subscribers_url": "https://api.github.com/repos/m-mizutani/masq/subscribers", 56 | "subscription_url": "https://api.github.com/repos/m-mizutani/masq/subscription", 57 | "commits_url": "https://api.github.com/repos/m-mizutani/masq/commits{/sha}", 58 | "git_commits_url": "https://api.github.com/repos/m-mizutani/masq/git/commits{/sha}", 59 | "comments_url": "https://api.github.com/repos/m-mizutani/masq/comments{/number}", 60 | "issue_comment_url": "https://api.github.com/repos/m-mizutani/masq/issues/comments{/number}", 61 | "contents_url": "https://api.github.com/repos/m-mizutani/masq/contents/{+path}", 62 | "compare_url": "https://api.github.com/repos/m-mizutani/masq/compare/{base}...{head}", 63 | "merges_url": "https://api.github.com/repos/m-mizutani/masq/merges", 64 | "archive_url": "https://api.github.com/repos/m-mizutani/masq/{archive_format}{/ref}", 65 | "downloads_url": "https://api.github.com/repos/m-mizutani/masq/downloads", 66 | "issues_url": "https://api.github.com/repos/m-mizutani/masq/issues{/number}", 67 | "pulls_url": "https://api.github.com/repos/m-mizutani/masq/pulls{/number}", 68 | "milestones_url": "https://api.github.com/repos/m-mizutani/masq/milestones{/number}", 69 | "notifications_url": "https://api.github.com/repos/m-mizutani/masq/notifications{?since,all,participating}", 70 | "labels_url": "https://api.github.com/repos/m-mizutani/masq/labels{/name}", 71 | "releases_url": "https://api.github.com/repos/m-mizutani/masq/releases{/id}", 72 | "deployments_url": "https://api.github.com/repos/m-mizutani/masq/deployments", 73 | "created_at": 1671955537, 74 | "updated_at": "2023-09-14T00:34:07Z", 75 | "pushed_at": 1694994180, 76 | "git_url": "git://github.com/m-mizutani/masq.git", 77 | "ssh_url": "git@github.com:m-mizutani/masq.git", 78 | "clone_url": "https://github.com/m-mizutani/masq.git", 79 | "svn_url": "https://github.com/m-mizutani/masq", 80 | "homepage": "", 81 | "size": 36, 82 | "stargazers_count": 48, 83 | "watchers_count": 48, 84 | "language": "Go", 85 | "has_issues": true, 86 | "has_projects": true, 87 | "has_downloads": true, 88 | "has_wiki": true, 89 | "has_pages": false, 90 | "has_discussions": false, 91 | "forks_count": 0, 92 | "mirror_url": null, 93 | "archived": false, 94 | "disabled": false, 95 | "open_issues_count": 1, 96 | "license": { 97 | "key": "apache-2.0", 98 | "name": "Apache License 2.0", 99 | "spdx_id": "Apache-2.0", 100 | "url": "https://api.github.com/licenses/apache-2.0", 101 | "node_id": "MDc6TGljZW5zZTI=" 102 | }, 103 | "allow_forking": true, 104 | "is_template": false, 105 | "web_commit_signoff_required": false, 106 | "topics": [], 107 | "visibility": "public", 108 | "forks": 0, 109 | "open_issues": 1, 110 | "watchers": 48, 111 | "default_branch": "main", 112 | "stargazers": 48, 113 | "master_branch": "main" 114 | }, 115 | "pusher": { 116 | "name": "m-mizutani", 117 | "email": "mizutani@hey.com" 118 | }, 119 | "sender": { 120 | "login": "m-mizutani", 121 | "id": 605953, 122 | "node_id": "MDQ6VXNlcjYwNTk1Mw==", 123 | "avatar_url": "https://avatars.githubusercontent.com/u/605953?v=4", 124 | "gravatar_id": "", 125 | "url": "https://api.github.com/users/m-mizutani", 126 | "html_url": "https://github.com/m-mizutani", 127 | "followers_url": "https://api.github.com/users/m-mizutani/followers", 128 | "following_url": "https://api.github.com/users/m-mizutani/following{/other_user}", 129 | "gists_url": "https://api.github.com/users/m-mizutani/gists{/gist_id}", 130 | "starred_url": "https://api.github.com/users/m-mizutani/starred{/owner}{/repo}", 131 | "subscriptions_url": "https://api.github.com/users/m-mizutani/subscriptions", 132 | "organizations_url": "https://api.github.com/users/m-mizutani/orgs", 133 | "repos_url": "https://api.github.com/users/m-mizutani/repos", 134 | "events_url": "https://api.github.com/users/m-mizutani/events{/privacy}", 135 | "received_events_url": "https://api.github.com/users/m-mizutani/received_events", 136 | "type": "User", 137 | "site_admin": false 138 | }, 139 | "installation": { 140 | "id": 41633205, 141 | "node_id": "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uNDE2MzMyMDU=" 142 | }, 143 | "created": true, 144 | "deleted": false, 145 | "forced": false, 146 | "base_ref": null, 147 | "compare": "https://github.com/m-mizutani/masq/commit/aa0378cad00d", 148 | "commits": [ 149 | { 150 | "id": "aa0378cad00d375c1897c1b5b5a4dd125984b511", 151 | "tree_id": "6bfe094e8bee7d87fca34add0b2e3713416adf3c", 152 | "distinct": true, 153 | "message": "Update packages", 154 | "timestamp": "2023-09-18T08:42:57+09:00", 155 | "url": "https://github.com/m-mizutani/masq/commit/aa0378cad00d375c1897c1b5b5a4dd125984b511", 156 | "author": { 157 | "name": "Masayoshi Mizutani", 158 | "email": "mizutani@hey.com", 159 | "username": "m-mizutani" 160 | }, 161 | "committer": { 162 | "name": "Masayoshi Mizutani", 163 | "email": "mizutani@hey.com", 164 | "username": "m-mizutani" 165 | }, 166 | "added": [], 167 | "removed": [], 168 | "modified": ["clone_test.go", "go.mod", "go.sum"] 169 | } 170 | ], 171 | "head_commit": { 172 | "id": "aa0378cad00d375c1897c1b5b5a4dd125984b511", 173 | "tree_id": "6bfe094e8bee7d87fca34add0b2e3713416adf3c", 174 | "distinct": true, 175 | "message": "Update packages", 176 | "timestamp": "2023-09-18T08:42:57+09:00", 177 | "url": "https://github.com/m-mizutani/masq/commit/aa0378cad00d375c1897c1b5b5a4dd125984b511", 178 | "author": { 179 | "name": "Masayoshi Mizutani", 180 | "email": "mizutani@hey.com", 181 | "username": "m-mizutani" 182 | }, 183 | "committer": { 184 | "name": "Masayoshi Mizutani", 185 | "email": "mizutani@hey.com", 186 | "username": "m-mizutani" 187 | }, 188 | "added": [], 189 | "removed": [], 190 | "modified": ["clone_test.go", "go.mod", "go.sum"] 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /pkg/infra/gh/client.go: -------------------------------------------------------------------------------- 1 | package gh 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | _ "embed" 7 | "encoding/json" 8 | "io" 9 | "log/slog" 10 | "net/http" 11 | "net/url" 12 | 13 | "github.com/bradleyfalzon/ghinstallation/v2" 14 | "github.com/google/go-github/v53/github" 15 | "github.com/m-mizutani/goerr/v2" 16 | "github.com/m-mizutani/octovy/pkg/domain/interfaces" 17 | "github.com/m-mizutani/octovy/pkg/domain/model" 18 | "github.com/m-mizutani/octovy/pkg/domain/types" 19 | "github.com/m-mizutani/octovy/pkg/utils" 20 | ) 21 | 22 | type Client struct { 23 | appID types.GitHubAppID 24 | pem types.GitHubAppPrivateKey 25 | enableCheckRuns bool 26 | } 27 | 28 | var _ interfaces.GitHub = (*Client)(nil) 29 | 30 | type ClientOption func(*Client) 31 | 32 | func WithEnableCheckRuns(enable bool) ClientOption { 33 | return func(c *Client) { 34 | c.enableCheckRuns = enable 35 | } 36 | } 37 | 38 | func New(appID types.GitHubAppID, pem types.GitHubAppPrivateKey, options ...ClientOption) (*Client, error) { 39 | if appID == 0 { 40 | return nil, goerr.Wrap(types.ErrInvalidOption, "appID is empty") 41 | } 42 | if pem == "" { 43 | return nil, goerr.Wrap(types.ErrInvalidOption, "pem is empty") 44 | } 45 | 46 | client := &Client{ 47 | appID: appID, 48 | pem: pem, 49 | } 50 | 51 | for _, opt := range options { 52 | opt(client) 53 | } 54 | 55 | return client, nil 56 | } 57 | 58 | func (x *Client) buildGithubClient(installID types.GitHubAppInstallID) (*github.Client, error) { 59 | httpClient, err := x.buildGithubHTTPClient(installID) 60 | if err != nil { 61 | return nil, err 62 | } 63 | return github.NewClient(httpClient), nil 64 | } 65 | 66 | func (x *Client) buildGithubHTTPClient(installID types.GitHubAppInstallID) (*http.Client, error) { 67 | tr := http.DefaultTransport 68 | itr, err := ghinstallation.New(tr, int64(x.appID), int64(installID), []byte(x.pem)) 69 | 70 | if err != nil { 71 | return nil, goerr.Wrap(err, "Failed to create github client") 72 | } 73 | 74 | client := &http.Client{Transport: itr} 75 | return client, nil 76 | } 77 | 78 | func (x *Client) GetArchiveURL(ctx context.Context, input *interfaces.GetArchiveURLInput) (*url.URL, error) { 79 | utils.CtxLogger(ctx).Info("Sending GetArchiveLink request", 80 | slog.Any("appID", x.appID), 81 | slog.Any("privateKey", x.pem), 82 | slog.Any("input", input), 83 | ) 84 | 85 | client, err := x.buildGithubClient(input.InstallID) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | opt := &github.RepositoryContentGetOptions{ 91 | Ref: input.CommitID, 92 | } 93 | 94 | // https://docs.github.com/en/rest/reference/repos#downloads 95 | // https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#get-archive-link 96 | url, r, err := client.Repositories.GetArchiveLink(ctx, input.Owner, input.Repo, github.Zipball, opt, false) 97 | if err != nil { 98 | return nil, goerr.Wrap(err, "failed to get archive link") 99 | } 100 | if r.StatusCode != http.StatusFound { 101 | body, _ := io.ReadAll(r.Body) 102 | return nil, goerr.New("Failed to get archive link", goerr.V("status", r.StatusCode), goerr.V("body", string(body))) 103 | } 104 | 105 | utils.CtxLogger(ctx).Debug("GetArchiveLink response", slog.Any("url", url), slog.Any("r", r)) 106 | 107 | return url, nil 108 | } 109 | 110 | func (x *Client) CreateIssue(ctx context.Context, id types.GitHubAppInstallID, repo *model.GitHubRepo, req *github.IssueRequest) (*github.Issue, error) { 111 | client, err := x.buildGithubClient(id) 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | issue, resp, err := client.Issues.Create(ctx, repo.Owner, repo.RepoName, req) 117 | if err != nil { 118 | return nil, goerr.Wrap(err, "Failed to create github comment", goerr.V("repo", repo), goerr.V("req", req)) 119 | } 120 | if resp.StatusCode != http.StatusCreated { 121 | return nil, goerr.New("failed to create issue", goerr.V("repo", repo), goerr.V("req", req), goerr.V("resp", resp)) 122 | } 123 | 124 | return issue, nil 125 | } 126 | 127 | func (x *Client) CreateIssueComment(ctx context.Context, repo *model.GitHubRepo, id types.GitHubAppInstallID, prID int, body string) error { 128 | client, err := x.buildGithubClient(id) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | comment := &github.IssueComment{Body: &body} 134 | 135 | ret, resp, err := client.Issues.CreateComment(ctx, repo.Owner, repo.RepoName, prID, comment) 136 | if err != nil { 137 | return goerr.Wrap(err, "Failed to create github comment", goerr.V("repo", repo), goerr.V("prID", prID), goerr.V("comment", comment)) 138 | } 139 | if resp.StatusCode != http.StatusCreated { 140 | return goerr.New("Failed to create comment", goerr.V("repo", repo), goerr.V("prID", prID), goerr.V("status", resp.StatusCode)) 141 | } 142 | utils.Logger().Debug("Commented to PR", "comment", ret) 143 | 144 | return nil 145 | } 146 | 147 | //go:embed queries/list_comments.graphql 148 | var queryListIssueComments string 149 | 150 | func (x *Client) ListIssueComments(ctx context.Context, repo *model.GitHubRepo, id types.GitHubAppInstallID, prID int) ([]*model.GitHubIssueComment, error) { 151 | type response struct { 152 | Repository struct { 153 | PullRequest struct { 154 | Comments struct { 155 | Edges []struct { 156 | Cursor string `json:"cursor"` 157 | Node struct { 158 | ID string `json:"id"` 159 | Author struct { 160 | Login string `json:"login"` 161 | } `json:"author"` 162 | Body string `json:"body"` 163 | IsMinimized bool `json:"isMinimized"` 164 | } `json:"node"` 165 | } `json:"edges"` 166 | } `json:"comments"` 167 | Title string `json:"title"` 168 | } `json:"pullRequest"` 169 | } `json:"repository"` 170 | } 171 | 172 | var comments []*model.GitHubIssueComment 173 | 174 | var cursor *string 175 | for { 176 | vars := map[string]any{ 177 | "owner": repo.Owner, 178 | "name": repo.RepoName, 179 | "issueNumber": prID, 180 | } 181 | if cursor != nil { 182 | vars["cursor"] = *cursor 183 | } 184 | resp, err := x.queryGraphQL(ctx, id, &gqlRequest{ 185 | Query: queryListIssueComments, 186 | Variables: vars, 187 | }) 188 | 189 | if err != nil { 190 | return nil, err 191 | } 192 | 193 | var data response 194 | if err := json.Unmarshal(resp.Data, &data); err != nil { 195 | return nil, goerr.Wrap(err, "Failed to unmarshal response") 196 | } 197 | 198 | if len(data.Repository.PullRequest.Comments.Edges) == 0 { 199 | break 200 | } 201 | 202 | for _, edge := range data.Repository.PullRequest.Comments.Edges { 203 | comments = append(comments, &model.GitHubIssueComment{ 204 | ID: edge.Node.ID, 205 | Login: edge.Node.Author.Login, 206 | Body: edge.Node.Body, 207 | IsMinimized: edge.Node.IsMinimized, 208 | }) 209 | cursor = &edge.Cursor 210 | } 211 | } 212 | 213 | return comments, nil 214 | } 215 | 216 | //go:embed queries/minimize_comment.graphql 217 | var queryMinimizeComment string 218 | 219 | func (x *Client) MinimizeComment(ctx context.Context, repo *model.GitHubRepo, id types.GitHubAppInstallID, subjectID string) error { 220 | req := &gqlRequest{ 221 | Query: queryMinimizeComment, 222 | Variables: map[string]any{ 223 | "id": subjectID, 224 | }, 225 | } 226 | 227 | _, err := x.queryGraphQL(ctx, id, req) 228 | if err != nil { 229 | return err 230 | } 231 | 232 | return nil 233 | } 234 | 235 | type gqlRequest struct { 236 | Query string `json:"query"` 237 | Variables map[string]any `json:"variables"` 238 | } 239 | type gqlResponse struct { 240 | Data json.RawMessage `json:"data"` 241 | Error json.RawMessage `json:"errors"` 242 | } 243 | 244 | func (x *Client) queryGraphQL(ctx context.Context, id types.GitHubAppInstallID, req *gqlRequest) (*gqlResponse, error) { 245 | client, err := x.buildGithubHTTPClient(id) 246 | if err != nil { 247 | return nil, err 248 | } 249 | 250 | rawReq, err := json.Marshal(req) 251 | if err != nil { 252 | return nil, goerr.Wrap(err, "Failed to marshal graphQL request", goerr.V("req", req)) 253 | } 254 | 255 | httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiGraphQLEndpoint, bytes.NewReader(rawReq)) 256 | if err != nil { 257 | return nil, goerr.Wrap(err, "Failed to create graphQL request", goerr.V("req", req)) 258 | } 259 | 260 | resp, err := client.Do(httpReq) 261 | if err != nil { 262 | return nil, goerr.Wrap(err, "Failed to send graphQL request", goerr.V("req", req)) 263 | } 264 | if resp.StatusCode != http.StatusOK { 265 | body, _ := io.ReadAll(resp.Body) 266 | return nil, goerr.New("Failed to get graphQL response", goerr.V("req", httpReq), goerr.V("resp", resp), goerr.V("body", string(body))) 267 | } 268 | 269 | var gqlResp gqlResponse 270 | body, err := io.ReadAll(resp.Body) 271 | if err != nil { 272 | return nil, goerr.Wrap(err, "Failed to read response body", goerr.V("resp", resp)) 273 | } 274 | if err := json.Unmarshal(body, &gqlResp); err != nil { 275 | return nil, goerr.Wrap(err, "Failed to decode response", goerr.V("resp", resp)) 276 | } 277 | 278 | return &gqlResp, nil 279 | } 280 | 281 | const ( 282 | apiGraphQLEndpoint = "https://api.github.com/graphql" 283 | ) 284 | 285 | func (x *Client) CreateCheckRun(ctx context.Context, id types.GitHubAppInstallID, repo *model.GitHubRepo, commit string) (int64, error) { 286 | if !x.enableCheckRuns { 287 | utils.CtxLogger(ctx).Debug("GitHub Check Runs are disabled, skipping check run creation") 288 | return 0, nil 289 | } 290 | 291 | client, err := x.buildGithubClient(id) 292 | if err != nil { 293 | return 0, err 294 | } 295 | 296 | opt := github.CreateCheckRunOptions{ 297 | Name: "Octovy: package vulnerability check", 298 | HeadSHA: commit, 299 | Status: github.String("in_progress"), 300 | } 301 | 302 | run, resp, err := client.Checks.CreateCheckRun(ctx, repo.Owner, repo.RepoName, opt) 303 | if err != nil { 304 | return 0, goerr.Wrap(err, "Failed to create check run", goerr.V("repo", repo), goerr.V("commit", commit)) 305 | } 306 | if resp.StatusCode != http.StatusCreated { 307 | return 0, goerr.New("Failed to create check run", goerr.V("repo", repo), goerr.V("commit", commit), goerr.V("status", resp.StatusCode)) 308 | } 309 | utils.CtxLogger(ctx).With("run", run).Debug("Created check run") 310 | 311 | return *run.ID, nil 312 | } 313 | 314 | func (x *Client) UpdateCheckRun(ctx context.Context, id types.GitHubAppInstallID, repo *model.GitHubRepo, checkID int64, opt *github.UpdateCheckRunOptions) error { 315 | if !x.enableCheckRuns { 316 | utils.CtxLogger(ctx).Debug("GitHub Check Runs are disabled, skipping check run update") 317 | return nil 318 | } 319 | 320 | client, err := x.buildGithubClient(id) 321 | if err != nil { 322 | return err 323 | } 324 | 325 | _, resp, err := client.Checks.UpdateCheckRun(ctx, repo.Owner, repo.RepoName, checkID, *opt) 326 | if err != nil { 327 | return goerr.Wrap(err, "Failed to update check status to complete", goerr.V("repo", repo), goerr.V("id", checkID), goerr.V("opt", opt)) 328 | } 329 | if resp.StatusCode != http.StatusOK { 330 | return goerr.New("Failed to update status to complete", goerr.V("repo", repo), goerr.V("checkID", checkID), goerr.V("status", resp.StatusCode)) 331 | } 332 | utils.CtxLogger(ctx).Debug("Updated check run", 333 | slog.Any("opt", opt), 334 | slog.Any("resp", resp), 335 | slog.Any("repo", repo), 336 | slog.Any("checkID", checkID), 337 | ) 338 | 339 | return nil 340 | } 341 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 Masayoshi Mizutani 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. --------------------------------------------------------------------------------