From c9506818a74158af487136087a12826c11234c96 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 09:48:14 -0500 Subject: [PATCH 01/18] Fix #1: Initialize Go module and project structure Signed-off-by: Brian Corder --- .env.example | 2 ++ .gitignore | 4 ++++ api/.gitkeep | 0 cmd/forkguard/main.go | 29 +++++++++++++++++++++++++++++ docs/rfcs/.gitkeep | 0 go.mod | 3 +++ internal/ancestry/doc.go | 16 ++++++++++++++++ internal/cli/doc.go | 21 +++++++++++++++++++++ internal/config/doc.go | 16 ++++++++++++++++ internal/ingest/ghsa/doc.go | 16 ++++++++++++++++ internal/ingest/normalize/doc.go | 16 ++++++++++++++++ internal/ingest/osv/doc.go | 16 ++++++++++++++++ internal/logging/doc.go | 16 ++++++++++++++++ internal/registry/doc.go | 16 ++++++++++++++++ internal/report/doc.go | 16 ++++++++++++++++ internal/rulegen/doc.go | 16 ++++++++++++++++ internal/scan/doc.go | 16 ++++++++++++++++ internal/store/gen/doc.go | 16 ++++++++++++++++ internal/store/migrations/.gitkeep | 0 internal/store/queries/.gitkeep | 0 pkg/advisory/doc.go | 16 ++++++++++++++++ pkg/client/doc.go | 16 ++++++++++++++++ pkg/finding/doc.go | 16 ++++++++++++++++ scripts/build-test-repos/.gitkeep | 0 tests/fixtures/advisories/.gitkeep | 0 tests/fixtures/repos/.gitkeep | 0 tests/integration/.gitkeep | 0 27 files changed, 283 insertions(+) create mode 100644 api/.gitkeep create mode 100644 cmd/forkguard/main.go create mode 100644 docs/rfcs/.gitkeep create mode 100644 go.mod create mode 100644 internal/ancestry/doc.go create mode 100644 internal/cli/doc.go create mode 100644 internal/config/doc.go create mode 100644 internal/ingest/ghsa/doc.go create mode 100644 internal/ingest/normalize/doc.go create mode 100644 internal/ingest/osv/doc.go create mode 100644 internal/logging/doc.go create mode 100644 internal/registry/doc.go create mode 100644 internal/report/doc.go create mode 100644 internal/rulegen/doc.go create mode 100644 internal/scan/doc.go create mode 100644 internal/store/gen/doc.go create mode 100644 internal/store/migrations/.gitkeep create mode 100644 internal/store/queries/.gitkeep create mode 100644 pkg/advisory/doc.go create mode 100644 pkg/client/doc.go create mode 100644 pkg/finding/doc.go create mode 100644 scripts/build-test-repos/.gitkeep create mode 100644 tests/fixtures/advisories/.gitkeep create mode 100644 tests/fixtures/repos/.gitkeep create mode 100644 tests/integration/.gitkeep diff --git a/.env.example b/.env.example index 9e7fb07..f8f60a8 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ # ForkGuard local configuration examples. # Do not put secrets in this file. +FORKGUARD_DB_PATH=~/.forkguard/state.db +FORKGUARD_CACHE_DIR=~/.forkguard/cache GITHUB_TOKEN= FORKGUARD_LOG_LEVEL=info FORKGUARD_LOG_FORMAT=json diff --git a/.gitignore b/.gitignore index 309a826..2b2404d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,9 @@ bin/ dist/ build/ +/forkguard coverage.out +*.coverprofile coverage/ reports/ deterministic-deps-report/ @@ -20,3 +22,5 @@ deterministic-deps-report/ # ForkGuard local state .forkguard/ .forkguard-rulegen/ +~/.forkguard/ +~/.forkguard-rulegen/ diff --git a/api/.gitkeep b/api/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/forkguard/main.go b/cmd/forkguard/main.go new file mode 100644 index 0000000..cae1abb --- /dev/null +++ b/cmd/forkguard/main.go @@ -0,0 +1,29 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "os" + + "github.com/Ozark-Security-Labs/forkguard/internal/cli" +) + +func main() { + if err := cli.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/docs/rfcs/.gitkeep b/docs/rfcs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..97c099d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/Ozark-Security-Labs/forkguard + +go 1.22 diff --git a/internal/ancestry/doc.go b/internal/ancestry/doc.go new file mode 100644 index 0000000..e1245e5 --- /dev/null +++ b/internal/ancestry/doc.go @@ -0,0 +1,16 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package ancestry contains ForkGuard ancestry functionality. +package ancestry diff --git a/internal/cli/doc.go b/internal/cli/doc.go new file mode 100644 index 0000000..09d1882 --- /dev/null +++ b/internal/cli/doc.go @@ -0,0 +1,21 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package cli contains the ForkGuard command-line interface. +package cli + +// Execute runs the ForkGuard CLI. +func Execute() error { + return nil +} diff --git a/internal/config/doc.go b/internal/config/doc.go new file mode 100644 index 0000000..a440f4f --- /dev/null +++ b/internal/config/doc.go @@ -0,0 +1,16 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package config contains ForkGuard config functionality. +package config diff --git a/internal/ingest/ghsa/doc.go b/internal/ingest/ghsa/doc.go new file mode 100644 index 0000000..b318680 --- /dev/null +++ b/internal/ingest/ghsa/doc.go @@ -0,0 +1,16 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package ghsa contains GitHub Security Advisory ingestion. +package ghsa diff --git a/internal/ingest/normalize/doc.go b/internal/ingest/normalize/doc.go new file mode 100644 index 0000000..6d507ef --- /dev/null +++ b/internal/ingest/normalize/doc.go @@ -0,0 +1,16 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package normalize contains advisory normalization. +package normalize diff --git a/internal/ingest/osv/doc.go b/internal/ingest/osv/doc.go new file mode 100644 index 0000000..9f20f57 --- /dev/null +++ b/internal/ingest/osv/doc.go @@ -0,0 +1,16 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package osv contains OSV advisory ingestion. +package osv diff --git a/internal/logging/doc.go b/internal/logging/doc.go new file mode 100644 index 0000000..188da4e --- /dev/null +++ b/internal/logging/doc.go @@ -0,0 +1,16 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package logging contains ForkGuard logging functionality. +package logging diff --git a/internal/registry/doc.go b/internal/registry/doc.go new file mode 100644 index 0000000..14fa101 --- /dev/null +++ b/internal/registry/doc.go @@ -0,0 +1,16 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package registry contains ForkGuard registry functionality. +package registry diff --git a/internal/report/doc.go b/internal/report/doc.go new file mode 100644 index 0000000..95d6ac0 --- /dev/null +++ b/internal/report/doc.go @@ -0,0 +1,16 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package report contains ForkGuard report functionality. +package report diff --git a/internal/rulegen/doc.go b/internal/rulegen/doc.go new file mode 100644 index 0000000..c4bdb47 --- /dev/null +++ b/internal/rulegen/doc.go @@ -0,0 +1,16 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package rulegen contains ForkGuard rulegen functionality. +package rulegen diff --git a/internal/scan/doc.go b/internal/scan/doc.go new file mode 100644 index 0000000..d484a9f --- /dev/null +++ b/internal/scan/doc.go @@ -0,0 +1,16 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package scan contains ForkGuard scan functionality. +package scan diff --git a/internal/store/gen/doc.go b/internal/store/gen/doc.go new file mode 100644 index 0000000..340dcb4 --- /dev/null +++ b/internal/store/gen/doc.go @@ -0,0 +1,16 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package gen contains generated store queries. +package gen diff --git a/internal/store/migrations/.gitkeep b/internal/store/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/store/queries/.gitkeep b/internal/store/queries/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pkg/advisory/doc.go b/pkg/advisory/doc.go new file mode 100644 index 0000000..da71a74 --- /dev/null +++ b/pkg/advisory/doc.go @@ -0,0 +1,16 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package advisory contains ForkGuard advisory functionality. +package advisory diff --git a/pkg/client/doc.go b/pkg/client/doc.go new file mode 100644 index 0000000..465f4ea --- /dev/null +++ b/pkg/client/doc.go @@ -0,0 +1,16 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package client contains ForkGuard client functionality. +package client diff --git a/pkg/finding/doc.go b/pkg/finding/doc.go new file mode 100644 index 0000000..bab5fea --- /dev/null +++ b/pkg/finding/doc.go @@ -0,0 +1,16 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package finding contains ForkGuard finding functionality. +package finding diff --git a/scripts/build-test-repos/.gitkeep b/scripts/build-test-repos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/advisories/.gitkeep b/tests/fixtures/advisories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/repos/.gitkeep b/tests/fixtures/repos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/.gitkeep b/tests/integration/.gitkeep new file mode 100644 index 0000000..e69de29 From 3666ecace7f72a359031df08e1ce4f053007a7f4 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 09:52:02 -0500 Subject: [PATCH 02/18] Fix #2: Set up linting, vetting, and CI Signed-off-by: Brian Corder --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++-------- .golangci.yaml | 29 +++++++++++++++++++++++++++++ Makefile | 17 +++++++++++++++++ 3 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 .golangci.yaml create mode 100644 Makefile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c750e92..6bf8283 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,13 +9,28 @@ permissions: contents: read jobs: - bootstrap: - runs-on: ubuntu-24.04 + go: + name: Go (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-14] steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - name: Validate bootstrap files - run: | - test -f README.md - test -f LICENSE - test -d docs - test -f .deterministic-deps.yml + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + with: + go-version: "1.22.x" + cache: true + - name: Install golangci-lint + run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 + - name: Vet + run: go vet ./... + - name: Lint + run: golangci-lint run ./... + - name: Test + run: go test ./... -race -coverprofile=coverage.out + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: coverage-${{ matrix.os }} + path: coverage.out diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..d576d13 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,29 @@ +run: + timeout: 5m + +linters: + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - gocritic + - gosec + - revive + - gofmt + - goimports + - misspell + - unconvert + - unparam + disable: + - varnamelen + - wsl + - lll + +issues: + exclude-rules: + - path: internal/store/gen/.*\.go + linters: + - gosec diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a5cb5f0 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +.PHONY: build lint vet test clean + +build: + go build ./cmd/forkguard + +lint: + golangci-lint run ./... + +vet: + go vet ./... + +test: + go test ./... -race -coverprofile=coverage.out + +clean: + rm -f forkguard coverage.out + rm -rf bin/ dist/ build/ coverage/ reports/ From 484e61796505c28360cb35437c80473298952804 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 09:52:42 -0500 Subject: [PATCH 03/18] Fix #3: Create issue and PR templates Signed-off-by: Brian Corder --- .../{bug_report.yml => bug-report.yml} | 0 .github/ISSUE_TEMPLATE/config.yml | 4 +-- ...eature_request.yml => feature-request.yml} | 0 .github/pull_request_template.md | 1 + SECURITY.md | 35 +++++++++++++++++-- 5 files changed, 35 insertions(+), 5 deletions(-) rename .github/ISSUE_TEMPLATE/{bug_report.yml => bug-report.yml} (100%) rename .github/ISSUE_TEMPLATE/{feature_request.yml => feature-request.yml} (100%) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/bug_report.yml rename to .github/ISSUE_TEMPLATE/bug-report.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index c4fdba3..d38e863 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Security vulnerability disclosure - url: https://github.com/Ozark-Security-Labs - about: Please use GitHub Security Advisories for private vulnerability reports. + url: https://github.com/Ozark-Security-Labs/forkguard/security/advisories/new + about: Please report vulnerabilities privately using GitHub Security Advisories; see SECURITY.md for details. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/feature_request.yml rename to .github/ISSUE_TEMPLATE/feature-request.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a52735a..347a6c6 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -18,4 +18,5 @@ What changed and why? ## DCO +- [ ] I have read [CONTRIBUTING.md](../CONTRIBUTING.md). - [ ] I certify this contribution under the Developer Certificate of Origin and signed off my commits with `git commit -s`. diff --git a/SECURITY.md b/SECURITY.md index 24ba1e2..41e2c68 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,36 @@ # Security Policy -Please report vulnerabilities privately through GitHub Security Advisories for this repository. +ForkGuard is defensive security software. Please do not report vulnerabilities in public issues until maintainers have investigated and coordinated a fix. -Do not open public issues for vulnerabilities until maintainers have investigated and coordinated a fix. Include a clear description, affected versions or commits, reproduction steps, and any suggested mitigations. +## Reporting a vulnerability -ForkGuard projects are defensive tools. Do not submit exploit automation, credential theft workflows, or live attack tooling. +Use GitHub Security Advisories for this repository: + + + +If you cannot use GitHub Security Advisories, contact the maintainers through the Ozark Security Labs GitHub organization and request a private disclosure channel. Do not include exploit details in public discussions. + +## Encryption + +GitHub Security Advisories provide the preferred private reporting workflow for this project. A standalone project GPG key is not currently published; if encrypted email disclosure is required, request current encryption instructions through the private advisory workflow before sending sensitive details. + +## Response targets + +Maintainers target the following response times for complete reports: + +- Initial acknowledgement: within 3 business days. +- Triage update: within 10 business days. +- Coordinated remediation plan: timing depends on severity, affected versions, and downstream coordination needs. + +## What to include + +Please include: + +- A clear description of the issue and security impact. +- Affected versions, commits, or configuration. +- Reproduction steps or a minimal proof of concept. +- Suggested mitigations or patches, if available. + +## Scope + +ForkGuard projects are defensive tools. Do not submit exploit automation, credential theft workflows, persistence tooling, or live attack tooling. From 009c8c96e106e848c4802d86e2a2042c9a23c85b Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 09:54:49 -0500 Subject: [PATCH 04/18] Fix #4: Set up cobra root command and stub subcommands Signed-off-by: Brian Corder --- go.mod | 7 ++++ go.sum | 10 ++++++ internal/cli/doc.go | 5 --- internal/cli/ingest.go | 31 +++++++++++++++++ internal/cli/init.go | 31 +++++++++++++++++ internal/cli/report.go | 31 +++++++++++++++++ internal/cli/root.go | 77 +++++++++++++++++++++++++++++++++++++++++ internal/cli/scan.go | 31 +++++++++++++++++ internal/cli/version.go | 27 +++++++++++++++ 9 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 go.sum create mode 100644 internal/cli/ingest.go create mode 100644 internal/cli/init.go create mode 100644 internal/cli/report.go create mode 100644 internal/cli/root.go create mode 100644 internal/cli/scan.go create mode 100644 internal/cli/version.go diff --git a/go.mod b/go.mod index 97c099d..9d41573 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ module github.com/Ozark-Security-Labs/forkguard go 1.22 + +require github.com/spf13/cobra v1.8.1 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..912390a --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/doc.go b/internal/cli/doc.go index 09d1882..e6bbde0 100644 --- a/internal/cli/doc.go +++ b/internal/cli/doc.go @@ -14,8 +14,3 @@ // Package cli contains the ForkGuard command-line interface. package cli - -// Execute runs the ForkGuard CLI. -func Execute() error { - return nil -} diff --git a/internal/cli/ingest.go b/internal/cli/ingest.go new file mode 100644 index 0000000..4fb0419 --- /dev/null +++ b/internal/cli/ingest.go @@ -0,0 +1,31 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "errors" + + "github.com/spf13/cobra" +) + +func newIngestCommand() *cobra.Command { + return &cobra.Command{ + Use: "ingest", + Short: "Fetch advisories from OSV/GHSA for an upstream", + RunE: func(*cobra.Command, []string) error { + return errors.New("not implemented") + }, + } +} diff --git a/internal/cli/init.go b/internal/cli/init.go new file mode 100644 index 0000000..827689b --- /dev/null +++ b/internal/cli/init.go @@ -0,0 +1,31 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "errors" + + "github.com/spf13/cobra" +) + +func newInitCommand() *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Initialize ForkGuard state and load forks.yaml", + RunE: func(*cobra.Command, []string) error { + return errors.New("not implemented") + }, + } +} diff --git a/internal/cli/report.go b/internal/cli/report.go new file mode 100644 index 0000000..852108c --- /dev/null +++ b/internal/cli/report.go @@ -0,0 +1,31 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "errors" + + "github.com/spf13/cobra" +) + +func newReportCommand() *cobra.Command { + return &cobra.Command{ + Use: "report", + Short: "Render scan results in JSON, SARIF, or table format", + RunE: func(*cobra.Command, []string) error { + return errors.New("not implemented") + }, + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..3ef8fa7 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,77 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" +) + +const ( + defaultLogFormat = "json" + defaultLogLevel = "info" +) + +type rootOptions struct { + configPath string + logFormat string + logLevel string +} + +// Execute runs the ForkGuard CLI. +func Execute() error { + return NewRootCommand().Execute() +} + +// NewRootCommand constructs the ForkGuard root command. +func NewRootCommand() *cobra.Command { + opts := rootOptions{} + cmd := &cobra.Command{ + Use: "forkguard", + Short: "Commit-aware SCA for forked dependencies", + SilenceUsage: true, + SilenceErrors: true, + } + + cmd.PersistentFlags().StringVar(&opts.configPath, "config", "", "path to a ForkGuard config file") + cmd.PersistentFlags().StringVar(&opts.logFormat, "log-format", defaultLogFormat, "log output format (json or text)") + cmd.PersistentFlags().StringVar(&opts.logLevel, "log-level", defaultLogLevel, "log level (debug, info, warn, or error)") + + _ = cmd.RegisterFlagCompletionFunc("log-format", completionFor("json", "text")) + _ = cmd.RegisterFlagCompletionFunc("log-level", completionFor("debug", "info", "warn", "error")) + + cmd.AddCommand( + newInitCommand(), + newIngestCommand(), + newScanCommand(), + newReportCommand(), + newVersionCommand(), + ) + + return cmd +} + +func completionFor(values ...string) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return values, cobra.ShellCompDirectiveNoFileComp + } +} + +func writeLine(w io.Writer, msg string) error { + _, err := fmt.Fprintln(w, msg) + return err +} diff --git a/internal/cli/scan.go b/internal/cli/scan.go new file mode 100644 index 0000000..d9614b4 --- /dev/null +++ b/internal/cli/scan.go @@ -0,0 +1,31 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "errors" + + "github.com/spf13/cobra" +) + +func newScanCommand() *cobra.Command { + return &cobra.Command{ + Use: "scan", + Short: "Run vulnerability scan against configured forks", + RunE: func(*cobra.Command, []string) error { + return errors.New("not implemented") + }, + } +} diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..39a14de --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,27 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import "github.com/spf13/cobra" + +func newVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print ForkGuard version", + RunE: func(cmd *cobra.Command, _ []string) error { + return writeLine(cmd.OutOrStdout(), "dev") + }, + } +} From aadabd316f68fca7992485ff6c99ed8dcda9f97a Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 09:57:17 -0500 Subject: [PATCH 05/18] Fix #5: Add viper-based configuration Signed-off-by: Brian Corder --- go.mod | 25 ++++- go.sum | 65 ++++++++++++ internal/cli/root.go | 27 +++-- internal/config/config.go | 182 +++++++++++++++++++++++++++++++++ internal/config/config_test.go | 171 +++++++++++++++++++++++++++++++ 5 files changed, 461 insertions(+), 9 deletions(-) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/go.mod b/go.mod index 9d41573..a7a30a3 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,30 @@ module github.com/Ozark-Security-Labs/forkguard go 1.22 -require github.com/spf13/cobra v1.8.1 +require ( + github.com/spf13/cobra v1.8.1 + github.com/spf13/pflag v1.0.5 + github.com/spf13/viper v1.18.2 +) require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 912390a..626d9ea 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,75 @@ github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/root.go b/internal/cli/root.go index 3ef8fa7..ca27c2a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -15,23 +15,22 @@ package cli import ( + "context" "fmt" "io" + "github.com/Ozark-Security-Labs/forkguard/internal/config" "github.com/spf13/cobra" ) -const ( - defaultLogFormat = "json" - defaultLogLevel = "info" -) - type rootOptions struct { configPath string logFormat string logLevel string } +type configContextKey struct{} + // Execute runs the ForkGuard CLI. func Execute() error { return NewRootCommand().Execute() @@ -45,11 +44,19 @@ func NewRootCommand() *cobra.Command { Short: "Commit-aware SCA for forked dependencies", SilenceUsage: true, SilenceErrors: true, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load(cmd) + if err != nil { + return err + } + cmd.SetContext(context.WithValue(cmd.Context(), configContextKey{}, cfg)) + return nil + }, } cmd.PersistentFlags().StringVar(&opts.configPath, "config", "", "path to a ForkGuard config file") - cmd.PersistentFlags().StringVar(&opts.logFormat, "log-format", defaultLogFormat, "log output format (json or text)") - cmd.PersistentFlags().StringVar(&opts.logLevel, "log-level", defaultLogLevel, "log level (debug, info, warn, or error)") + cmd.PersistentFlags().StringVar(&opts.logFormat, "log-format", config.DefaultLogFormat, "log output format (json or text)") + cmd.PersistentFlags().StringVar(&opts.logLevel, "log-level", config.DefaultLogLevel, "log level (debug, info, warn, or error)") _ = cmd.RegisterFlagCompletionFunc("log-format", completionFor("json", "text")) _ = cmd.RegisterFlagCompletionFunc("log-level", completionFor("debug", "info", "warn", "error")) @@ -65,6 +72,12 @@ func NewRootCommand() *cobra.Command { return cmd } +// ConfigFromCommand returns the loaded ForkGuard configuration for a command. +func ConfigFromCommand(cmd *cobra.Command) (*config.Config, bool) { + cfg, ok := cmd.Context().Value(configContextKey{}).(*config.Config) + return cfg, ok +} + func completionFor(values ...string) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { return func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { return values, cobra.ShellCompDirectiveNoFileComp diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..850257f --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,182 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +const ( + // DefaultDBPath is the default ForkGuard SQLite database location. + DefaultDBPath = "~/.forkguard/state.db" + // DefaultCacheDir is the default ForkGuard cache directory. + DefaultCacheDir = "~/.forkguard/cache" + // DefaultConfigPath is the default ForkGuard YAML configuration path. + DefaultConfigPath = "~/.forkguard/config.yaml" + // DefaultLogFormat is the default structured log format. + DefaultLogFormat = "json" + // DefaultLogLevel is the default structured log level. + DefaultLogLevel = "info" +) + +// Config contains ForkGuard runtime configuration. +type Config struct { + DBPath string + CacheDir string + GitHubToken string + LogFormat string + LogLevel string +} + +// Load reads ForkGuard configuration from defaults, an optional YAML file, +// environment variables, and changed CLI flags, in increasing precedence. +func Load(cmd *cobra.Command) (*Config, error) { + v := viper.New() + v.SetConfigType("yaml") + v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + + setDefaults(v) + bindEnv(v) + + configPath, explicitConfig, err := configPathFromCommand(cmd) + if err != nil { + return nil, err + } + if err := readConfigFile(v, configPath, explicitConfig); err != nil { + return nil, err + } + + cfg := &Config{ + DBPath: v.GetString("db_path"), + CacheDir: v.GetString("cache_dir"), + GitHubToken: v.GetString("github_token"), + LogFormat: v.GetString("log_format"), + LogLevel: v.GetString("log_level"), + } + + applyChangedStringFlag(cmd, "log-format", &cfg.LogFormat) + applyChangedStringFlag(cmd, "log-level", &cfg.LogLevel) + + cfg.DBPath, err = ExpandPath(cfg.DBPath) + if err != nil { + return nil, fmt.Errorf("expand db path: %w", err) + } + cfg.CacheDir, err = ExpandPath(cfg.CacheDir) + if err != nil { + return nil, fmt.Errorf("expand cache dir: %w", err) + } + + return cfg, nil +} + +// ExpandPath expands a leading tilde to the current user's home directory. +func ExpandPath(path string) (string, error) { + if path == "" || path[0] != '~' { + return path, nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + if path == "~" { + return home, nil + } + if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) { + return filepath.Join(home, path[2:]), nil + } + + return "", fmt.Errorf("unsupported home-relative path %q", path) +} + +func setDefaults(v *viper.Viper) { + v.SetDefault("db_path", DefaultDBPath) + v.SetDefault("cache_dir", DefaultCacheDir) + v.SetDefault("github_token", "") + v.SetDefault("log_format", DefaultLogFormat) + v.SetDefault("log_level", DefaultLogLevel) +} + +func bindEnv(v *viper.Viper) { + _ = v.BindEnv("db_path", "FORKGUARD_DB_PATH") + _ = v.BindEnv("cache_dir", "FORKGUARD_CACHE_DIR") + _ = v.BindEnv("github_token", "GITHUB_TOKEN") + _ = v.BindEnv("log_format", "FORKGUARD_LOG_FORMAT") + _ = v.BindEnv("log_level", "FORKGUARD_LOG_LEVEL") +} + +func readConfigFile(v *viper.Viper, path string, explicit bool) error { + expanded, err := ExpandPath(path) + if err != nil { + return err + } + v.SetConfigFile(expanded) + if err := v.ReadInConfig(); err != nil { + var notFound viper.ConfigFileNotFoundError + if !explicit && errors.As(err, ¬Found) { + return nil + } + if !explicit && os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read config file %q: %w", expanded, err) + } + + return nil +} + +func configPathFromCommand(cmd *cobra.Command) (string, bool, error) { + flag := findFlag(cmd, "config") + if flag == nil { + return DefaultConfigPath, false, nil + } + value := flag.Value.String() + if value == "" { + return DefaultConfigPath, false, nil + } + + return value, flag.Changed, nil +} + +func applyChangedStringFlag(cmd *cobra.Command, name string, target *string) { + flag := findFlag(cmd, name) + if flag != nil && flag.Changed { + *target = flag.Value.String() + } +} + +func findFlag(cmd *cobra.Command, name string) *pflag.Flag { + for current := cmd; current != nil; current = current.Parent() { + if flag := current.Flags().Lookup(name); flag != nil { + return flag + } + if flag := current.PersistentFlags().Lookup(name); flag != nil { + return flag + } + if flag := current.InheritedFlags().Lookup(name); flag != nil { + return flag + } + } + + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..80d38d2 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,171 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" +) + +func TestLoadDefaults(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + cmd := testCommand(t) + cfg, err := Load(cmd) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + wantDBPath := filepath.Join(os.Getenv("HOME"), ".forkguard", "state.db") + if cfg.DBPath != wantDBPath { + t.Fatalf("DBPath = %q, want %q", cfg.DBPath, wantDBPath) + } + if cfg.CacheDir != filepath.Join(os.Getenv("HOME"), ".forkguard", "cache") { + t.Fatalf("CacheDir = %q", cfg.CacheDir) + } + if cfg.LogFormat != DefaultLogFormat { + t.Fatalf("LogFormat = %q, want %q", cfg.LogFormat, DefaultLogFormat) + } + if cfg.LogLevel != DefaultLogLevel { + t.Fatalf("LogLevel = %q, want %q", cfg.LogLevel, DefaultLogLevel) + } +} + +func TestLoadFromConfigFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + configPath := filepath.Join(t.TempDir(), "config.yaml") + writeFile(t, configPath, `db_path: ~/from-config/state.db +cache_dir: ~/from-config/cache +github_token: config-token +log_format: text +log_level: debug +`) + + cmd := testCommand(t, "--config", configPath) + cfg, err := Load(cmd) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.DBPath != filepath.Join(home, "from-config", "state.db") { + t.Fatalf("DBPath = %q", cfg.DBPath) + } + if cfg.CacheDir != filepath.Join(home, "from-config", "cache") { + t.Fatalf("CacheDir = %q", cfg.CacheDir) + } + if cfg.GitHubToken != "config-token" { + t.Fatalf("GitHubToken = %q", cfg.GitHubToken) + } + if cfg.LogFormat != "text" { + t.Fatalf("LogFormat = %q", cfg.LogFormat) + } + if cfg.LogLevel != "debug" { + t.Fatalf("LogLevel = %q", cfg.LogLevel) + } +} + +func TestLoadEnvOverridesConfigFile(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + configPath := filepath.Join(t.TempDir(), "config.yaml") + writeFile(t, configPath, `db_path: /config/state.db +cache_dir: /config/cache +github_token: config-token +log_format: text +log_level: debug +`) + t.Setenv("FORKGUARD_DB_PATH", "/env/state.db") + t.Setenv("FORKGUARD_CACHE_DIR", "/env/cache") + t.Setenv("GITHUB_TOKEN", "env-token") + t.Setenv("FORKGUARD_LOG_FORMAT", "json") + t.Setenv("FORKGUARD_LOG_LEVEL", "warn") + + cmd := testCommand(t, "--config", configPath) + cfg, err := Load(cmd) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.DBPath != "/env/state.db" { + t.Fatalf("DBPath = %q", cfg.DBPath) + } + if cfg.CacheDir != "/env/cache" { + t.Fatalf("CacheDir = %q", cfg.CacheDir) + } + if cfg.GitHubToken != "env-token" { + t.Fatalf("GitHubToken = %q", cfg.GitHubToken) + } + if cfg.LogFormat != "json" { + t.Fatalf("LogFormat = %q", cfg.LogFormat) + } + if cfg.LogLevel != "warn" { + t.Fatalf("LogLevel = %q", cfg.LogLevel) + } +} + +func TestLoadFlagsOverrideEnvAndConfigFile(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + configPath := filepath.Join(t.TempDir(), "config.yaml") + writeFile(t, configPath, `log_format: text +log_level: debug +`) + t.Setenv("FORKGUARD_LOG_FORMAT", "text") + t.Setenv("FORKGUARD_LOG_LEVEL", "warn") + + cmd := testCommand(t, "--config", configPath, "--log-format", "json", "--log-level", "error") + cfg, err := Load(cmd) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.LogFormat != "json" { + t.Fatalf("LogFormat = %q", cfg.LogFormat) + } + if cfg.LogLevel != "error" { + t.Fatalf("LogLevel = %q", cfg.LogLevel) + } +} + +func TestExpandPathRejectsOtherUsers(t *testing.T) { + if _, err := ExpandPath("~other/.forkguard/state.db"); err == nil { + t.Fatal("ExpandPath() error = nil, want error") + } +} + +func testCommand(t *testing.T, args ...string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "forkguard", Run: func(*cobra.Command, []string) {}} + cmd.PersistentFlags().String("config", "", "path to a ForkGuard config file") + cmd.PersistentFlags().String("log-format", DefaultLogFormat, "log output format") + cmd.PersistentFlags().String("log-level", DefaultLogLevel, "log level") + cmd.SetArgs(args) + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + return cmd +} + +func writeFile(t *testing.T, path string, contents string) { + t.Helper() + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } +} From 6c4c9ee76afa74c1001414f7729b84d988c89a00 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 09:59:15 -0500 Subject: [PATCH 06/18] Fix #6: Set up slog-based structured logging Signed-off-by: Brian Corder --- cmd/forkguard/main.go | 4 +- internal/cli/ingest.go | 2 + internal/cli/init.go | 2 + internal/cli/report.go | 2 + internal/cli/root.go | 2 + internal/cli/scan.go | 2 + internal/logging/logging.go | 54 +++++++++++++++++++++++++++ internal/logging/logging_test.go | 63 ++++++++++++++++++++++++++++++++ 8 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 internal/logging/logging.go create mode 100644 internal/logging/logging_test.go diff --git a/cmd/forkguard/main.go b/cmd/forkguard/main.go index cae1abb..663852f 100644 --- a/cmd/forkguard/main.go +++ b/cmd/forkguard/main.go @@ -15,7 +15,7 @@ package main import ( - "fmt" + "log/slog" "os" "github.com/Ozark-Security-Labs/forkguard/internal/cli" @@ -23,7 +23,7 @@ import ( func main() { if err := cli.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) + slog.Error(err.Error()) os.Exit(1) } } diff --git a/internal/cli/ingest.go b/internal/cli/ingest.go index 4fb0419..b9fb165 100644 --- a/internal/cli/ingest.go +++ b/internal/cli/ingest.go @@ -16,6 +16,7 @@ package cli import ( "errors" + "log/slog" "github.com/spf13/cobra" ) @@ -25,6 +26,7 @@ func newIngestCommand() *cobra.Command { Use: "ingest", Short: "Fetch advisories from OSV/GHSA for an upstream", RunE: func(*cobra.Command, []string) error { + slog.Info("command not implemented", "command", "ingest") return errors.New("not implemented") }, } diff --git a/internal/cli/init.go b/internal/cli/init.go index 827689b..37bfa28 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -16,6 +16,7 @@ package cli import ( "errors" + "log/slog" "github.com/spf13/cobra" ) @@ -25,6 +26,7 @@ func newInitCommand() *cobra.Command { Use: "init", Short: "Initialize ForkGuard state and load forks.yaml", RunE: func(*cobra.Command, []string) error { + slog.Info("command not implemented", "command", "init") return errors.New("not implemented") }, } diff --git a/internal/cli/report.go b/internal/cli/report.go index 852108c..cbd72dc 100644 --- a/internal/cli/report.go +++ b/internal/cli/report.go @@ -16,6 +16,7 @@ package cli import ( "errors" + "log/slog" "github.com/spf13/cobra" ) @@ -25,6 +26,7 @@ func newReportCommand() *cobra.Command { Use: "report", Short: "Render scan results in JSON, SARIF, or table format", RunE: func(*cobra.Command, []string) error { + slog.Info("command not implemented", "command", "report") return errors.New("not implemented") }, } diff --git a/internal/cli/root.go b/internal/cli/root.go index ca27c2a..a8dfcd6 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -20,6 +20,7 @@ import ( "io" "github.com/Ozark-Security-Labs/forkguard/internal/config" + "github.com/Ozark-Security-Labs/forkguard/internal/logging" "github.com/spf13/cobra" ) @@ -49,6 +50,7 @@ func NewRootCommand() *cobra.Command { if err != nil { return err } + logging.Setup(cfg.LogFormat, cfg.LogLevel) cmd.SetContext(context.WithValue(cmd.Context(), configContextKey{}, cfg)) return nil }, diff --git a/internal/cli/scan.go b/internal/cli/scan.go index d9614b4..1392dad 100644 --- a/internal/cli/scan.go +++ b/internal/cli/scan.go @@ -16,6 +16,7 @@ package cli import ( "errors" + "log/slog" "github.com/spf13/cobra" ) @@ -25,6 +26,7 @@ func newScanCommand() *cobra.Command { Use: "scan", Short: "Run vulnerability scan against configured forks", RunE: func(*cobra.Command, []string) error { + slog.Info("command not implemented", "command", "scan") return errors.New("not implemented") }, } diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..46a818c --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,54 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logging + +import ( + "io" + "log/slog" + "os" + "strings" +) + +// Setup configures the process-wide structured logger. +func Setup(format, level string) *slog.Logger { + return setupWithWriter(format, level, os.Stderr) +} + +func setupWithWriter(format, level string, w io.Writer) *slog.Logger { + handlerOptions := &slog.HandlerOptions{Level: parseLevel(level)} + var handler slog.Handler + if strings.EqualFold(format, "text") { + handler = slog.NewTextHandler(w, handlerOptions) + } else { + handler = slog.NewJSONHandler(w, handlerOptions) + } + + logger := slog.New(handler) + slog.SetDefault(logger) + return logger +} + +func parseLevel(level string) slog.Leveler { + switch strings.ToLower(level) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go new file mode 100644 index 0000000..b20a870 --- /dev/null +++ b/internal/logging/logging_test.go @@ -0,0 +1,63 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logging + +import ( + "bytes" + "encoding/json" + "log/slog" + "strings" + "testing" +) + +func TestSetupWithWriterJSON(t *testing.T) { + var buf bytes.Buffer + logger := setupWithWriter("json", "debug", &buf) + logger.Debug("configured", slog.String("component", "test")) + + var record map[string]string + if err := json.Unmarshal(buf.Bytes(), &record); err != nil { + t.Fatalf("json.Unmarshal() error = %v; output %q", err, buf.String()) + } + if record["level"] != "DEBUG" { + t.Fatalf("level = %q, want DEBUG", record["level"]) + } + if record["msg"] != "configured" { + t.Fatalf("msg = %q, want configured", record["msg"]) + } +} + +func TestSetupWithWriterText(t *testing.T) { + var buf bytes.Buffer + logger := setupWithWriter("text", "info", &buf) + logger.Info("configured", slog.String("component", "test")) + + output := buf.String() + if !strings.Contains(output, "level=INFO") { + t.Fatalf("output %q does not contain level=INFO", output) + } + if !strings.Contains(output, "msg=configured") { + t.Fatalf("output %q does not contain msg=configured", output) + } +} + +func TestSetupSetsDefaultLogger(t *testing.T) { + var buf bytes.Buffer + logger := setupWithWriter("json", "info", &buf) + + if slog.Default() != logger { + t.Fatal("slog.Default() was not set to configured logger") + } +} From 6c480c3354417777a90153dcbbdf91290142c51a Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:03:59 -0500 Subject: [PATCH 07/18] Fix #7: Set up SQLite store with golang-migrate Signed-off-by: Brian Corder --- go.mod | 16 ++- go.sum | 60 ++++++++- internal/cli/init.go | 20 ++- .../store/migrations/0001_initial.down.sql | 7 ++ internal/store/migrations/0001_initial.up.sql | 71 +++++++++++ internal/store/store.go | 114 ++++++++++++++++++ internal/store/store_test.go | 100 +++++++++++++++ 7 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 internal/store/migrations/0001_initial.down.sql create mode 100644 internal/store/migrations/0001_initial.up.sql create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go diff --git a/go.mod b/go.mod index a7a30a3..ca0d7a6 100644 --- a/go.mod +++ b/go.mod @@ -3,18 +3,27 @@ module github.com/Ozark-Security-Labs/forkguard go 1.22 require ( + github.com/golang-migrate/migrate/v4 v4.17.1 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 + modernc.org/sqlite v1.34.5 ) require ( + github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -24,8 +33,11 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/sys v0.15.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.16.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.8.0 // indirect ) diff --git a/go.sum b/go.sum index 626d9ea..9b44eb8 100644 --- a/go.sum +++ b/go.sum @@ -3,12 +3,25 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= +github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -17,15 +30,23 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -61,10 +82,17 @@ go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -73,3 +101,27 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/cli/init.go b/internal/cli/init.go index 37bfa28..a90989b 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -18,6 +18,7 @@ import ( "errors" "log/slog" + "github.com/Ozark-Security-Labs/forkguard/internal/store" "github.com/spf13/cobra" ) @@ -25,9 +26,22 @@ func newInitCommand() *cobra.Command { return &cobra.Command{ Use: "init", Short: "Initialize ForkGuard state and load forks.yaml", - RunE: func(*cobra.Command, []string) error { - slog.Info("command not implemented", "command", "init") - return errors.New("not implemented") + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, ok := ConfigFromCommand(cmd) + if !ok { + return errors.New("configuration not loaded") + } + s, err := store.New(cmd.Context(), cfg.DBPath) + if err != nil { + return err + } + defer func() { + if err := s.Close(); err != nil { + slog.Error("failed to close store", "error", err) + } + }() + slog.Info("initialized ForkGuard state", "db_path", cfg.DBPath) + return nil }, } } diff --git a/internal/store/migrations/0001_initial.down.sql b/internal/store/migrations/0001_initial.down.sql new file mode 100644 index 0000000..686fd42 --- /dev/null +++ b/internal/store/migrations/0001_initial.down.sql @@ -0,0 +1,7 @@ +DROP TABLE IF EXISTS findings; +DROP TABLE IF EXISTS scans; +DROP TABLE IF EXISTS fix_commits; +DROP TABLE IF EXISTS advisory_upstreams; +DROP TABLE IF EXISTS advisories; +DROP TABLE IF EXISTS forks; +DROP TABLE IF EXISTS upstreams; diff --git a/internal/store/migrations/0001_initial.up.sql b/internal/store/migrations/0001_initial.up.sql new file mode 100644 index 0000000..33afb83 --- /dev/null +++ b/internal/store/migrations/0001_initial.up.sql @@ -0,0 +1,71 @@ +CREATE TABLE upstreams ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + clone_url TEXT NOT NULL, + package_ecosystem TEXT, + package_name TEXT, + last_ingested_at DATETIME +); + +CREATE TABLE forks ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + clone_url TEXT NOT NULL, + upstream_id INTEGER NOT NULL, + tracked_ref TEXT NOT NULL DEFAULT 'HEAD', + FOREIGN KEY (upstream_id) REFERENCES upstreams(id) ON DELETE CASCADE +); + +CREATE TABLE advisories ( + id INTEGER PRIMARY KEY, + source TEXT NOT NULL, + source_id TEXT NOT NULL, + summary TEXT NOT NULL, + severity TEXT, + cvss_vector TEXT, + published_at DATETIME, + modified_at DATETIME, + raw_json TEXT NOT NULL, + UNIQUE (source, source_id) +); + +CREATE TABLE advisory_upstreams ( + advisory_id INTEGER NOT NULL, + upstream_id INTEGER NOT NULL, + PRIMARY KEY (advisory_id, upstream_id), + FOREIGN KEY (advisory_id) REFERENCES advisories(id) ON DELETE CASCADE, + FOREIGN KEY (upstream_id) REFERENCES upstreams(id) ON DELETE CASCADE +); + +CREATE TABLE fix_commits ( + id INTEGER PRIMARY KEY, + advisory_id INTEGER NOT NULL, + upstream_id INTEGER NOT NULL, + commit_sha TEXT NOT NULL, + confidence TEXT NOT NULL, + FOREIGN KEY (advisory_id) REFERENCES advisories(id) ON DELETE CASCADE, + FOREIGN KEY (upstream_id) REFERENCES upstreams(id) ON DELETE CASCADE, + UNIQUE (advisory_id, upstream_id, commit_sha) +); + +CREATE TABLE scans ( + id INTEGER PRIMARY KEY, + fork_id INTEGER NOT NULL, + started_at DATETIME NOT NULL, + finished_at DATETIME, + fork_head_sha TEXT NOT NULL, + upstream_head_sha TEXT NOT NULL, + status TEXT NOT NULL, + FOREIGN KEY (fork_id) REFERENCES forks(id) ON DELETE CASCADE +); + +CREATE TABLE findings ( + id INTEGER PRIMARY KEY, + scan_id INTEGER NOT NULL, + advisory_id INTEGER NOT NULL, + fix_commit_sha TEXT NOT NULL, + status TEXT NOT NULL, + evidence_json TEXT NOT NULL, + FOREIGN KEY (scan_id) REFERENCES scans(id) ON DELETE CASCADE, + FOREIGN KEY (advisory_id) REFERENCES advisories(id) ON DELETE CASCADE +); diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..87796c0 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,114 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package store + +import ( + "context" + "database/sql" + "embed" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/sqlite" + "github.com/golang-migrate/migrate/v4/source/iofs" + _ "modernc.org/sqlite" +) + +//go:embed migrations/*.sql +var migrationFiles embed.FS + +// Store wraps the ForkGuard SQLite database handle. +type Store struct { + db *sql.DB +} + +// New opens the SQLite store at dbPath and runs all pending migrations. +func New(ctx context.Context, dbPath string) (*Store, error) { + if err := os.MkdirAll(filepath.Dir(dbPath), 0o700); err != nil { + return nil, fmt.Errorf("create database directory: %w", err) + } + + db, err := sql.Open("sqlite", sqliteDSN(dbPath)) + if err != nil { + return nil, fmt.Errorf("open sqlite database: %w", err) + } + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, fmt.Errorf("ping sqlite database: %w", err) + } + if err := runMigrations(db); err != nil { + _ = db.Close() + return nil, err + } + + return &Store{db: db}, nil +} + +// Close closes the underlying database connection. +func (s *Store) Close() error { + if s == nil || s.db == nil { + return nil + } + return s.db.Close() +} + +// DB returns the underlying database handle for generated queries. +func (s *Store) DB() *sql.DB { + return s.db +} + +func runMigrations(db *sql.DB) error { + migrator, err := newMigrator(db) + if err != nil { + return err + } + + if err := migrator.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("run migrations: %w", err) + } + + return nil +} + +func newMigrator(db *sql.DB) (*migrate.Migrate, error) { + source, err := iofs.New(migrationFiles, "migrations") + if err != nil { + return nil, fmt.Errorf("create migration source: %w", err) + } + driver, err := sqlite.WithInstance(db, &sqlite.Config{}) + if err != nil { + return nil, fmt.Errorf("create sqlite migration driver: %w", err) + } + migrator, err := migrate.NewWithInstance("iofs", source, "sqlite", driver) + if err != nil { + return nil, fmt.Errorf("create migrator: %w", err) + } + + return migrator, nil +} + +func sqliteDSN(path string) string { + path = filepath.ToSlash(path) + if strings.HasPrefix(path, "/") { + return "file:" + path + "?_pragma=foreign_keys(1)&_pragma=journal_mode(wal)" + } + + return "file:" + url.PathEscape(path) + "?_pragma=foreign_keys(1)&_pragma=journal_mode(wal)" +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..4e00563 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,100 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package store + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "testing" + + "github.com/golang-migrate/migrate/v4" +) + +func TestNewRunsMigrations(t *testing.T) { + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "state.db") + + s, err := New(ctx, dbPath) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer func() { + if err := s.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + }() + + for _, table := range []string{ + "upstreams", + "forks", + "advisories", + "advisory_upstreams", + "fix_commits", + "scans", + "findings", + } { + if !tableExists(t, s.DB(), table) { + t.Fatalf("table %q does not exist", table) + } + } +} + +func TestDownMigrationDropsTables(t *testing.T) { + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "state.db") + + s, err := New(ctx, dbPath) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer func() { + if err := s.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + }() + + migrator, err := newMigrator(s.DB()) + if err != nil { + t.Fatalf("newMigrator() error = %v", err) + } + defer func() { + _, _ = migrator.Close() + }() + if err := migrator.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + t.Fatalf("Down() error = %v", err) + } + + for _, table := range []string{"upstreams", "forks", "advisories", "fix_commits", "scans", "findings"} { + if tableExists(t, s.DB(), table) { + t.Fatalf("table %q still exists after down migration", table) + } + } +} + +func tableExists(t *testing.T, db *sql.DB, table string) bool { + t.Helper() + var name string + err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&name) + if errors.Is(err, sql.ErrNoRows) { + return false + } + if err != nil { + t.Fatalf("query sqlite_master: %v", err) + } + + return name == table +} From 5bead2cc268e96884ee57f6b3bd21e6afd99dbed Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:06:10 -0500 Subject: [PATCH 08/18] Fix #8: Configure sqlc and write initial queries Signed-off-by: Brian Corder --- Makefile | 7 +- internal/store/gen/advisories.sql.go | 167 +++++++++++++++++++++++++ internal/store/gen/db.go | 45 +++++++ internal/store/gen/fix_commits.sql.go | 133 ++++++++++++++++++++ internal/store/gen/models.go | 85 +++++++++++++ internal/store/gen/upstreams.sql.go | 93 ++++++++++++++ internal/store/queries/advisories.sql | 35 ++++++ internal/store/queries/fix_commits.sql | 18 +++ internal/store/queries/upstreams.sql | 14 +++ scripts/add-go-license-headers.sh | 28 +++++ sqlc.yaml | 10 ++ 11 files changed, 634 insertions(+), 1 deletion(-) create mode 100644 internal/store/gen/advisories.sql.go create mode 100644 internal/store/gen/db.go create mode 100644 internal/store/gen/fix_commits.sql.go create mode 100644 internal/store/gen/models.go create mode 100644 internal/store/gen/upstreams.sql.go create mode 100644 internal/store/queries/advisories.sql create mode 100644 internal/store/queries/fix_commits.sql create mode 100644 internal/store/queries/upstreams.sql create mode 100755 scripts/add-go-license-headers.sh create mode 100644 sqlc.yaml diff --git a/Makefile b/Makefile index a5cb5f0..5482e2f 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,13 @@ -.PHONY: build lint vet test clean +.PHONY: build generate lint vet test clean build: go build ./cmd/forkguard +generate: + sqlc generate + ./scripts/add-go-license-headers.sh internal/store/gen/*.go + gofmt -w internal/store/gen/*.go + lint: golangci-lint run ./... diff --git a/internal/store/gen/advisories.sql.go b/internal/store/gen/advisories.sql.go new file mode 100644 index 0000000..1172355 --- /dev/null +++ b/internal/store/gen/advisories.sql.go @@ -0,0 +1,167 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: advisories.sql + +package gen + +import ( + "context" + "database/sql" +) + +const getAdvisoryBySourceID = `-- name: GetAdvisoryBySourceID :one +SELECT id, source, source_id, summary, severity, cvss_vector, published_at, modified_at, raw_json +FROM advisories +WHERE source = ? AND source_id = ? +` + +type GetAdvisoryBySourceIDParams struct { + Source string + SourceID string +} + +func (q *Queries) GetAdvisoryBySourceID(ctx context.Context, arg GetAdvisoryBySourceIDParams) (Advisory, error) { + row := q.db.QueryRowContext(ctx, getAdvisoryBySourceID, arg.Source, arg.SourceID) + var i Advisory + err := row.Scan( + &i.ID, + &i.Source, + &i.SourceID, + &i.Summary, + &i.Severity, + &i.CvssVector, + &i.PublishedAt, + &i.ModifiedAt, + &i.RawJson, + ) + return i, err +} + +const linkAdvisoryToUpstream = `-- name: LinkAdvisoryToUpstream :exec +INSERT OR IGNORE INTO advisory_upstreams (advisory_id, upstream_id) +VALUES (?, ?) +` + +type LinkAdvisoryToUpstreamParams struct { + AdvisoryID int64 + UpstreamID int64 +} + +func (q *Queries) LinkAdvisoryToUpstream(ctx context.Context, arg LinkAdvisoryToUpstreamParams) error { + _, err := q.db.ExecContext(ctx, linkAdvisoryToUpstream, arg.AdvisoryID, arg.UpstreamID) + return err +} + +const listAdvisoriesForUpstream = `-- name: ListAdvisoriesForUpstream :many +SELECT a.id, a.source, a.source_id, a.summary, a.severity, a.cvss_vector, a.published_at, a.modified_at, a.raw_json +FROM advisories AS a +JOIN advisory_upstreams AS au ON au.advisory_id = a.id +WHERE au.upstream_id = ? +ORDER BY a.source, a.source_id +` + +func (q *Queries) ListAdvisoriesForUpstream(ctx context.Context, upstreamID int64) ([]Advisory, error) { + rows, err := q.db.QueryContext(ctx, listAdvisoriesForUpstream, upstreamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Advisory + for rows.Next() { + var i Advisory + if err := rows.Scan( + &i.ID, + &i.Source, + &i.SourceID, + &i.Summary, + &i.Severity, + &i.CvssVector, + &i.PublishedAt, + &i.ModifiedAt, + &i.RawJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertAdvisory = `-- name: UpsertAdvisory :one +INSERT INTO advisories ( + source, + source_id, + summary, + severity, + cvss_vector, + published_at, + modified_at, + raw_json +) VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (source, source_id) DO UPDATE SET + summary = excluded.summary, + severity = excluded.severity, + cvss_vector = excluded.cvss_vector, + published_at = excluded.published_at, + modified_at = excluded.modified_at, + raw_json = excluded.raw_json +RETURNING id, source, source_id, summary, severity, cvss_vector, published_at, modified_at, raw_json +` + +type UpsertAdvisoryParams struct { + Source string + SourceID string + Summary string + Severity sql.NullString + CvssVector sql.NullString + PublishedAt sql.NullTime + ModifiedAt sql.NullTime + RawJson string +} + +func (q *Queries) UpsertAdvisory(ctx context.Context, arg UpsertAdvisoryParams) (Advisory, error) { + row := q.db.QueryRowContext(ctx, upsertAdvisory, + arg.Source, + arg.SourceID, + arg.Summary, + arg.Severity, + arg.CvssVector, + arg.PublishedAt, + arg.ModifiedAt, + arg.RawJson, + ) + var i Advisory + err := row.Scan( + &i.ID, + &i.Source, + &i.SourceID, + &i.Summary, + &i.Severity, + &i.CvssVector, + &i.PublishedAt, + &i.ModifiedAt, + &i.RawJson, + ) + return i, err +} diff --git a/internal/store/gen/db.go b/internal/store/gen/db.go new file mode 100644 index 0000000..85a5f0a --- /dev/null +++ b/internal/store/gen/db.go @@ -0,0 +1,45 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package gen + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/store/gen/fix_commits.sql.go b/internal/store/gen/fix_commits.sql.go new file mode 100644 index 0000000..5bd42a1 --- /dev/null +++ b/internal/store/gen/fix_commits.sql.go @@ -0,0 +1,133 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: fix_commits.sql + +package gen + +import ( + "context" +) + +const countFixCommitsByConfidence = `-- name: CountFixCommitsByConfidence :many +SELECT confidence, COUNT(*) AS count +FROM fix_commits +GROUP BY confidence +ORDER BY confidence +` + +type CountFixCommitsByConfidenceRow struct { + Confidence string + Count int64 +} + +func (q *Queries) CountFixCommitsByConfidence(ctx context.Context) ([]CountFixCommitsByConfidenceRow, error) { + rows, err := q.db.QueryContext(ctx, countFixCommitsByConfidence) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CountFixCommitsByConfidenceRow + for rows.Next() { + var i CountFixCommitsByConfidenceRow + if err := rows.Scan(&i.Confidence, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listFixCommitsForAdvisoryAndUpstream = `-- name: ListFixCommitsForAdvisoryAndUpstream :many +SELECT id, advisory_id, upstream_id, commit_sha, confidence +FROM fix_commits +WHERE advisory_id = ? AND upstream_id = ? +ORDER BY commit_sha +` + +type ListFixCommitsForAdvisoryAndUpstreamParams struct { + AdvisoryID int64 + UpstreamID int64 +} + +func (q *Queries) ListFixCommitsForAdvisoryAndUpstream(ctx context.Context, arg ListFixCommitsForAdvisoryAndUpstreamParams) ([]FixCommit, error) { + rows, err := q.db.QueryContext(ctx, listFixCommitsForAdvisoryAndUpstream, arg.AdvisoryID, arg.UpstreamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FixCommit + for rows.Next() { + var i FixCommit + if err := rows.Scan( + &i.ID, + &i.AdvisoryID, + &i.UpstreamID, + &i.CommitSha, + &i.Confidence, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertFixCommit = `-- name: UpsertFixCommit :one +INSERT INTO fix_commits (advisory_id, upstream_id, commit_sha, confidence) +VALUES (?, ?, ?, ?) +ON CONFLICT (advisory_id, upstream_id, commit_sha) DO UPDATE SET + confidence = excluded.confidence +RETURNING id, advisory_id, upstream_id, commit_sha, confidence +` + +type UpsertFixCommitParams struct { + AdvisoryID int64 + UpstreamID int64 + CommitSha string + Confidence string +} + +func (q *Queries) UpsertFixCommit(ctx context.Context, arg UpsertFixCommitParams) (FixCommit, error) { + row := q.db.QueryRowContext(ctx, upsertFixCommit, + arg.AdvisoryID, + arg.UpstreamID, + arg.CommitSha, + arg.Confidence, + ) + var i FixCommit + err := row.Scan( + &i.ID, + &i.AdvisoryID, + &i.UpstreamID, + &i.CommitSha, + &i.Confidence, + ) + return i, err +} diff --git a/internal/store/gen/models.go b/internal/store/gen/models.go new file mode 100644 index 0000000..05e38bb --- /dev/null +++ b/internal/store/gen/models.go @@ -0,0 +1,85 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package gen + +import ( + "database/sql" + "time" +) + +type Advisory struct { + ID int64 + Source string + SourceID string + Summary string + Severity sql.NullString + CvssVector sql.NullString + PublishedAt sql.NullTime + ModifiedAt sql.NullTime + RawJson string +} + +type AdvisoryUpstream struct { + AdvisoryID int64 + UpstreamID int64 +} + +type Finding struct { + ID int64 + ScanID int64 + AdvisoryID int64 + FixCommitSha string + Status string + EvidenceJson string +} + +type FixCommit struct { + ID int64 + AdvisoryID int64 + UpstreamID int64 + CommitSha string + Confidence string +} + +type Fork struct { + ID int64 + Name string + CloneUrl string + UpstreamID int64 + TrackedRef string +} + +type Scan struct { + ID int64 + ForkID int64 + StartedAt time.Time + FinishedAt sql.NullTime + ForkHeadSha string + UpstreamHeadSha string + Status string +} + +type Upstream struct { + ID int64 + Name string + CloneUrl string + PackageEcosystem sql.NullString + PackageName sql.NullString + LastIngestedAt sql.NullTime +} diff --git a/internal/store/gen/upstreams.sql.go b/internal/store/gen/upstreams.sql.go new file mode 100644 index 0000000..ce103ca --- /dev/null +++ b/internal/store/gen/upstreams.sql.go @@ -0,0 +1,93 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: upstreams.sql + +package gen + +import ( + "context" + "database/sql" +) + +const createUpstream = `-- name: CreateUpstream :one +INSERT INTO upstreams (name, clone_url, package_ecosystem, package_name) +VALUES (?, ?, ?, ?) +RETURNING id, name, clone_url, package_ecosystem, package_name, last_ingested_at +` + +type CreateUpstreamParams struct { + Name string + CloneUrl string + PackageEcosystem sql.NullString + PackageName sql.NullString +} + +func (q *Queries) CreateUpstream(ctx context.Context, arg CreateUpstreamParams) (Upstream, error) { + row := q.db.QueryRowContext(ctx, createUpstream, + arg.Name, + arg.CloneUrl, + arg.PackageEcosystem, + arg.PackageName, + ) + var i Upstream + err := row.Scan( + &i.ID, + &i.Name, + &i.CloneUrl, + &i.PackageEcosystem, + &i.PackageName, + &i.LastIngestedAt, + ) + return i, err +} + +const getUpstreamByName = `-- name: GetUpstreamByName :one +SELECT id, name, clone_url, package_ecosystem, package_name, last_ingested_at +FROM upstreams +WHERE name = ? +` + +func (q *Queries) GetUpstreamByName(ctx context.Context, name string) (Upstream, error) { + row := q.db.QueryRowContext(ctx, getUpstreamByName, name) + var i Upstream + err := row.Scan( + &i.ID, + &i.Name, + &i.CloneUrl, + &i.PackageEcosystem, + &i.PackageName, + &i.LastIngestedAt, + ) + return i, err +} + +const updateUpstreamIngestedAt = `-- name: UpdateUpstreamIngestedAt :exec +UPDATE upstreams +SET last_ingested_at = ? +WHERE id = ? +` + +type UpdateUpstreamIngestedAtParams struct { + LastIngestedAt sql.NullTime + ID int64 +} + +func (q *Queries) UpdateUpstreamIngestedAt(ctx context.Context, arg UpdateUpstreamIngestedAtParams) error { + _, err := q.db.ExecContext(ctx, updateUpstreamIngestedAt, arg.LastIngestedAt, arg.ID) + return err +} diff --git a/internal/store/queries/advisories.sql b/internal/store/queries/advisories.sql new file mode 100644 index 0000000..6855476 --- /dev/null +++ b/internal/store/queries/advisories.sql @@ -0,0 +1,35 @@ +-- name: UpsertAdvisory :one +INSERT INTO advisories ( + source, + source_id, + summary, + severity, + cvss_vector, + published_at, + modified_at, + raw_json +) VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (source, source_id) DO UPDATE SET + summary = excluded.summary, + severity = excluded.severity, + cvss_vector = excluded.cvss_vector, + published_at = excluded.published_at, + modified_at = excluded.modified_at, + raw_json = excluded.raw_json +RETURNING id, source, source_id, summary, severity, cvss_vector, published_at, modified_at, raw_json; + +-- name: GetAdvisoryBySourceID :one +SELECT id, source, source_id, summary, severity, cvss_vector, published_at, modified_at, raw_json +FROM advisories +WHERE source = ? AND source_id = ?; + +-- name: LinkAdvisoryToUpstream :exec +INSERT OR IGNORE INTO advisory_upstreams (advisory_id, upstream_id) +VALUES (?, ?); + +-- name: ListAdvisoriesForUpstream :many +SELECT a.id, a.source, a.source_id, a.summary, a.severity, a.cvss_vector, a.published_at, a.modified_at, a.raw_json +FROM advisories AS a +JOIN advisory_upstreams AS au ON au.advisory_id = a.id +WHERE au.upstream_id = ? +ORDER BY a.source, a.source_id; diff --git a/internal/store/queries/fix_commits.sql b/internal/store/queries/fix_commits.sql new file mode 100644 index 0000000..4e03ab3 --- /dev/null +++ b/internal/store/queries/fix_commits.sql @@ -0,0 +1,18 @@ +-- name: UpsertFixCommit :one +INSERT INTO fix_commits (advisory_id, upstream_id, commit_sha, confidence) +VALUES (?, ?, ?, ?) +ON CONFLICT (advisory_id, upstream_id, commit_sha) DO UPDATE SET + confidence = excluded.confidence +RETURNING id, advisory_id, upstream_id, commit_sha, confidence; + +-- name: ListFixCommitsForAdvisoryAndUpstream :many +SELECT id, advisory_id, upstream_id, commit_sha, confidence +FROM fix_commits +WHERE advisory_id = ? AND upstream_id = ? +ORDER BY commit_sha; + +-- name: CountFixCommitsByConfidence :many +SELECT confidence, COUNT(*) AS count +FROM fix_commits +GROUP BY confidence +ORDER BY confidence; diff --git a/internal/store/queries/upstreams.sql b/internal/store/queries/upstreams.sql new file mode 100644 index 0000000..02ffcb9 --- /dev/null +++ b/internal/store/queries/upstreams.sql @@ -0,0 +1,14 @@ +-- name: GetUpstreamByName :one +SELECT id, name, clone_url, package_ecosystem, package_name, last_ingested_at +FROM upstreams +WHERE name = ?; + +-- name: CreateUpstream :one +INSERT INTO upstreams (name, clone_url, package_ecosystem, package_name) +VALUES (?, ?, ?, ?) +RETURNING id, name, clone_url, package_ecosystem, package_name, last_ingested_at; + +-- name: UpdateUpstreamIngestedAt :exec +UPDATE upstreams +SET last_ingested_at = ? +WHERE id = ?; diff --git a/scripts/add-go-license-headers.sh b/scripts/add-go-license-headers.sh new file mode 100755 index 0000000..9037cdd --- /dev/null +++ b/scripts/add-go-license-headers.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +header='// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +' + +for file in "$@"; do + if [[ -f "$file" ]] && ! grep -q "Apache License" "$file"; then + tmp=$(mktemp) + { + printf '%s\n' "$header" + cat "$file" + } > "$tmp" + mv "$tmp" "$file" + fi +done diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..01ed752 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,10 @@ +version: "2" +sql: + - engine: sqlite + queries: internal/store/queries + schema: internal/store/migrations/0001_initial.up.sql + gen: + go: + package: gen + out: internal/store/gen + sql_package: database/sql From 94e2242fdc9fb8f55f20af22737b9c5e3bd51e7e Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:08:59 -0500 Subject: [PATCH 09/18] Fix #9: Implement OSV API client Signed-off-by: Brian Corder --- internal/ingest/osv/client.go | 275 ++++++++++++++++++ internal/ingest/osv/client_test.go | 155 ++++++++++ internal/ingest/osv/types.go | 86 ++++++ tests/fixtures/advisories/sample-cve-osv.json | 39 +++ 4 files changed, 555 insertions(+) create mode 100644 internal/ingest/osv/client.go create mode 100644 internal/ingest/osv/client_test.go create mode 100644 internal/ingest/osv/types.go create mode 100644 tests/fixtures/advisories/sample-cve-osv.json diff --git a/internal/ingest/osv/client.go b/internal/ingest/osv/client.go new file mode 100644 index 0000000..8c782cd --- /dev/null +++ b/internal/ingest/osv/client.go @@ -0,0 +1,275 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package osv + +import ( + "archive/zip" + "bytes" + "context" + cryptorand "crypto/rand" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "path" + "strings" + "time" +) + +const ( + defaultAPIBaseURL = "https://api.osv.dev" + defaultStorageBaseURL = "https://osv-vulnerabilities.storage.googleapis.com" + maxAttempts = 3 +) + +// Option configures an OSV client. +type Option func(*Client) + +// Client is an HTTP client for OSV.dev APIs and bulk advisory downloads. +type Client struct { + httpClient *http.Client + apiBaseURL string + storageBaseURL string + backoff func(int) time.Duration +} + +// New constructs an OSV API client. +func New(opts ...Option) *Client { + c := &Client{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + apiBaseURL: defaultAPIBaseURL, + storageBaseURL: defaultStorageBaseURL, + backoff: retryBackoff, + } + for _, opt := range opts { + opt(c) + } + + return c +} + +// WithHTTPClient overrides the HTTP client used for requests. +func WithHTTPClient(httpClient *http.Client) Option { + return func(c *Client) { + if httpClient != nil { + c.httpClient = httpClient + } + } +} + +// WithBaseURL overrides the OSV API base URL. +func WithBaseURL(baseURL string) Option { + return func(c *Client) { + c.apiBaseURL = strings.TrimRight(baseURL, "/") + } +} + +// WithStorageBaseURL overrides the OSV bulk storage base URL. +func WithStorageBaseURL(baseURL string) Option { + return func(c *Client) { + c.storageBaseURL = strings.TrimRight(baseURL, "/") + } +} + +// WithBackoff overrides retry sleep durations. It is primarily useful in tests. +func WithBackoff(backoff func(int) time.Duration) Option { + return func(c *Client) { + if backoff != nil { + c.backoff = backoff + } + } +} + +// QueryByRepo queries OSV for vulnerabilities associated with a source repo. +func (c *Client) QueryByRepo(ctx context.Context, repoURL string) ([]Vulnerability, error) { + var vulns []Vulnerability + pageToken := "" + for { + payload := queryRequest{Repo: repoURL, PageToken: pageToken} + var response queryResponse + if err := c.postJSON(ctx, "/v1/query", payload, &response); err != nil { + return nil, err + } + vulns = append(vulns, response.Vulns...) + if response.NextPageToken == "" { + break + } + pageToken = response.NextPageToken + } + + return vulns, nil +} + +// DownloadEcosystem downloads and decodes an OSV ecosystem all.zip archive. +func (c *Client) DownloadEcosystem(ctx context.Context, ecosystem string) ([]Vulnerability, error) { + archiveURL, err := url.JoinPath(c.storageBaseURL, path.Clean(ecosystem), "all.zip") + if err != nil { + return nil, fmt.Errorf("build ecosystem download URL: %w", err) + } + body, err := c.getBytes(ctx, archiveURL) + if err != nil { + return nil, err + } + + reader, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + if err != nil { + return nil, fmt.Errorf("open OSV ecosystem zip: %w", err) + } + + vulns := make([]Vulnerability, 0, len(reader.File)) + for _, file := range reader.File { + if file.FileInfo().IsDir() || !strings.HasSuffix(file.Name, ".json") { + continue + } + vuln, err := readZipVulnerability(file) + if err != nil { + return nil, err + } + vulns = append(vulns, vuln) + } + + return vulns, nil +} + +func (c *Client) postJSON(ctx context.Context, endpoint string, payload any, target any) error { + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("encode OSV request: %w", err) + } + + requestURL := strings.TrimRight(c.apiBaseURL, "/") + endpoint + responseBody, err := c.doWithRetry(ctx, func() (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + return req, nil + }) + if err != nil { + return err + } + if err := json.Unmarshal(responseBody, target); err != nil { + return fmt.Errorf("decode OSV response: %w", err) + } + + return nil +} + +func (c *Client) getBytes(ctx context.Context, requestURL string) ([]byte, error) { + return c.doWithRetry(ctx, func() (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/zip") + return req, nil + }) +} + +func (c *Client) doWithRetry(ctx context.Context, buildRequest func() (*http.Request, error)) ([]byte, error) { + var lastErr error + for attempt := 0; attempt < maxAttempts; attempt++ { + req, err := buildRequest() + if err != nil { + return nil, fmt.Errorf("build OSV request: %w", err) + } + body, retry, err := c.do(req) + if err == nil { + return body, nil + } + lastErr = err + if !retry || attempt == maxAttempts-1 { + break + } + if err := sleep(ctx, c.backoff(attempt)); err != nil { + return nil, err + } + } + + return nil, lastErr +} + +func (c *Client) do(req *http.Request) ([]byte, bool, error) { + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, true, fmt.Errorf("send OSV request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, false, fmt.Errorf("read OSV response: %w", err) + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return body, false, nil + } + + retry := resp.StatusCode >= 500 + return nil, retry, fmt.Errorf("OSV request failed with status %d: %s", resp.StatusCode, string(body)) +} + +func readZipVulnerability(file *zip.File) (Vulnerability, error) { + r, err := file.Open() + if err != nil { + return Vulnerability{}, fmt.Errorf("open %s from OSV zip: %w", file.Name, err) + } + defer r.Close() + + var vuln Vulnerability + if err := json.NewDecoder(r).Decode(&vuln); err != nil { + return Vulnerability{}, fmt.Errorf("decode %s from OSV zip: %w", file.Name, err) + } + + return vuln, nil +} + +func sleep(ctx context.Context, duration time.Duration) error { + if duration <= 0 { + return nil + } + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func retryBackoff(attempt int) time.Duration { + base := 100 * time.Millisecond * time.Duration(1< Date: Sat, 23 May 2026 10:10:28 -0500 Subject: [PATCH 10/18] Fix #10: Implement OSV advisory normalization Signed-off-by: Brian Corder --- internal/ingest/normalize/normalize.go | 155 ++++++++++++++++++++ internal/ingest/normalize/normalize_test.go | 142 ++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 internal/ingest/normalize/normalize.go create mode 100644 internal/ingest/normalize/normalize_test.go diff --git a/internal/ingest/normalize/normalize.go b/internal/ingest/normalize/normalize.go new file mode 100644 index 0000000..c061397 --- /dev/null +++ b/internal/ingest/normalize/normalize.go @@ -0,0 +1,155 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package normalize + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + "time" + + "github.com/Ozark-Security-Labs/forkguard/internal/ingest/osv" +) + +const ( + // SourceOSV identifies advisories imported from OSV.dev. + SourceOSV = "OSV" + // ConfidenceExplicit marks fix commits explicitly declared by an advisory. + ConfidenceExplicit = "EXPLICIT" +) + +// Upstream identifies an upstream repository being ingested. +type Upstream struct { + Name string + CloneURL string +} + +// Advisory is ForkGuard's canonical advisory representation. +type Advisory struct { + Source string + SourceID string + Summary string + Severity string + CVSSVector string + PublishedAt time.Time + ModifiedAt time.Time + RawJSON string +} + +// FixCommit is ForkGuard's canonical fix commit representation. +type FixCommit struct { + CommitSHA string + Confidence string +} + +// NormalizeOSV converts an OSV vulnerability into ForkGuard advisory records. +func NormalizeOSV(raw osv.Vulnerability, upstream Upstream) (Advisory, []FixCommit, error) { + rawJSON, err := json.Marshal(raw) + if err != nil { + return Advisory{}, nil, fmt.Errorf("marshal raw OSV advisory: %w", err) + } + + advisory := Advisory{ + Source: SourceOSV, + SourceID: raw.ID, + Summary: firstNonEmpty(raw.Summary, raw.Details), + Severity: extractSeverity(raw), + CVSSVector: extractCVSSVector(raw), + PublishedAt: raw.Published, + ModifiedAt: raw.Modified, + RawJSON: string(rawJSON), + } + + fixCommits := make([]FixCommit, 0) + seen := make(map[string]struct{}) + for _, affected := range raw.Affected { + for _, affectedRange := range affected.Ranges { + if !strings.EqualFold(affectedRange.Type, "GIT") || !repoMatches(affectedRange.Repo, upstream) { + continue + } + for _, event := range affectedRange.Events { + if event.Fixed == "" { + continue + } + if _, ok := seen[event.Fixed]; ok { + continue + } + seen[event.Fixed] = struct{}{} + fixCommits = append(fixCommits, FixCommit{ + CommitSHA: event.Fixed, + Confidence: ConfidenceExplicit, + }) + } + } + } + + return advisory, fixCommits, nil +} + +func extractSeverity(raw osv.Vulnerability) string { + if value, ok := raw.DatabaseSpecific["severity"].(string); ok { + return value + } + if value, ok := raw.DatabaseSpecific["Severity"].(string); ok { + return value + } + return "" +} + +func extractCVSSVector(raw osv.Vulnerability) string { + for _, severity := range raw.Severity { + if strings.HasPrefix(strings.ToUpper(severity.Type), "CVSS") { + return severity.Score + } + } + return "" +} + +func repoMatches(repo string, upstream Upstream) bool { + candidate := canonicalRepo(repo) + if candidate == "" { + return false + } + return candidate == canonicalRepo(upstream.Name) || candidate == canonicalRepo(upstream.CloneURL) +} + +func canonicalRepo(repo string) string { + repo = strings.TrimSpace(repo) + repo = strings.TrimSuffix(repo, "/") + repo = strings.TrimSuffix(repo, ".git") + if repo == "" { + return "" + } + + parsed, err := url.Parse(repo) + if err == nil && parsed.Host != "" { + return strings.ToLower(strings.Trim(parsed.Host+parsed.Path, "/")) + } + + repo = strings.TrimPrefix(repo, "git@") + repo = strings.Replace(repo, ":", "/", 1) + repo = strings.Trim(repo, "/") + return strings.ToLower(repo) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/internal/ingest/normalize/normalize_test.go b/internal/ingest/normalize/normalize_test.go new file mode 100644 index 0000000..ac25301 --- /dev/null +++ b/internal/ingest/normalize/normalize_test.go @@ -0,0 +1,142 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package normalize + +import ( + "encoding/json" + "testing" + "time" + + "github.com/Ozark-Security-Labs/forkguard/internal/ingest/osv" +) + +func TestNormalizeOSVSingleGitRange(t *testing.T) { + raw := sampleVulnerability() + advisory, fixCommits, err := NormalizeOSV(raw, sampleUpstream()) + if err != nil { + t.Fatalf("NormalizeOSV() error = %v", err) + } + + if advisory.Source != SourceOSV { + t.Fatalf("Source = %q", advisory.Source) + } + if advisory.SourceID != raw.ID { + t.Fatalf("SourceID = %q", advisory.SourceID) + } + if advisory.Summary != raw.Summary { + t.Fatalf("Summary = %q", advisory.Summary) + } + if advisory.Severity != "HIGH" { + t.Fatalf("Severity = %q", advisory.Severity) + } + if advisory.CVSSVector != "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" { + t.Fatalf("CVSSVector = %q", advisory.CVSSVector) + } + if advisory.PublishedAt != raw.Published { + t.Fatalf("PublishedAt = %v", advisory.PublishedAt) + } + if advisory.ModifiedAt != raw.Modified { + t.Fatalf("ModifiedAt = %v", advisory.ModifiedAt) + } + var decoded osv.Vulnerability + if err := json.Unmarshal([]byte(advisory.RawJSON), &decoded); err != nil { + t.Fatalf("RawJSON did not decode: %v", err) + } + if len(fixCommits) != 1 { + t.Fatalf("len(fixCommits) = %d, want 1", len(fixCommits)) + } + if fixCommits[0].CommitSHA != "0123456789abcdef0123456789abcdef01234567" { + t.Fatalf("CommitSHA = %q", fixCommits[0].CommitSHA) + } + if fixCommits[0].Confidence != ConfidenceExplicit { + t.Fatalf("Confidence = %q", fixCommits[0].Confidence) + } +} + +func TestNormalizeOSVMultipleGitRanges(t *testing.T) { + raw := sampleVulnerability() + raw.Affected[0].Ranges = append(raw.Affected[0].Ranges, osv.Range{ + Type: "GIT", + Repo: "github.com/openssl/openssl", + Events: []osv.RangeEvent{ + {Introduced: "0"}, + {Fixed: "abcdefabcdefabcdefabcdefabcdefabcdefabcd"}, + }, + }) + + _, fixCommits, err := NormalizeOSV(raw, sampleUpstream()) + if err != nil { + t.Fatalf("NormalizeOSV() error = %v", err) + } + if len(fixCommits) != 2 { + t.Fatalf("len(fixCommits) = %d, want 2", len(fixCommits)) + } +} + +func TestNormalizeOSVNoGitRanges(t *testing.T) { + raw := sampleVulnerability() + raw.Affected[0].Ranges = []osv.Range{{Type: "SEMVER", Events: []osv.RangeEvent{{Fixed: "1.2.3"}}}} + + _, fixCommits, err := NormalizeOSV(raw, sampleUpstream()) + if err != nil { + t.Fatalf("NormalizeOSV() error = %v", err) + } + if len(fixCommits) != 0 { + t.Fatalf("len(fixCommits) = %d, want 0", len(fixCommits)) + } +} + +func TestNormalizeOSVNonMatchingRepo(t *testing.T) { + raw := sampleVulnerability() + raw.Affected[0].Ranges[0].Repo = "https://github.com/example/not-openssl" + + _, fixCommits, err := NormalizeOSV(raw, sampleUpstream()) + if err != nil { + t.Fatalf("NormalizeOSV() error = %v", err) + } + if len(fixCommits) != 0 { + t.Fatalf("len(fixCommits) = %d, want 0", len(fixCommits)) + } +} + +func sampleUpstream() Upstream { + return Upstream{Name: "github.com/openssl/openssl", CloneURL: "https://github.com/openssl/openssl.git"} +} + +func sampleVulnerability() osv.Vulnerability { + published := time.Date(2023, 3, 21, 12, 0, 0, 0, time.UTC) + modified := time.Date(2023, 3, 22, 12, 0, 0, 0, time.UTC) + return osv.Vulnerability{ + ID: "CVE-2023-0464", + Summary: "OpenSSL sample vulnerability fixture", + Published: published, + Modified: modified, + Severity: []osv.Severity{{ + Type: "CVSS_V3", + Score: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + }}, + DatabaseSpecific: map[string]any{"severity": "HIGH"}, + Affected: []osv.Affected{{ + Ranges: []osv.Range{{ + Type: "GIT", + Repo: "https://github.com/openssl/openssl", + Events: []osv.RangeEvent{ + {Introduced: "0"}, + {Fixed: "0123456789abcdef0123456789abcdef01234567"}, + }, + }}, + }}, + } +} From ed39d2cdba422301bcc3a801dc84fe52a18ab95e Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:18:12 -0500 Subject: [PATCH 11/18] Fix #11: Wire up forkguard init and ingest Signed-off-by: Brian Corder --- internal/cli/ingest.go | 42 +++++- internal/cli/init.go | 28 +++- internal/config/config.go | 14 +- internal/ingest/ingest.go | 174 +++++++++++++++++++++++++ internal/ingest/ingest_test.go | 121 +++++++++++++++++ internal/ingest/normalize/normalize.go | 2 + internal/ingest/osv/client.go | 41 +++++- internal/ingest/osv/client_test.go | 34 +++++ internal/store/store.go | 2 +- 9 files changed, 440 insertions(+), 18 deletions(-) create mode 100644 internal/ingest/ingest.go create mode 100644 internal/ingest/ingest_test.go diff --git a/internal/cli/ingest.go b/internal/cli/ingest.go index b9fb165..385584d 100644 --- a/internal/cli/ingest.go +++ b/internal/cli/ingest.go @@ -18,16 +18,50 @@ import ( "errors" "log/slog" + "github.com/Ozark-Security-Labs/forkguard/internal/ingest" + "github.com/Ozark-Security-Labs/forkguard/internal/ingest/osv" + "github.com/Ozark-Security-Labs/forkguard/internal/store" "github.com/spf13/cobra" ) func newIngestCommand() *cobra.Command { - return &cobra.Command{ + var upstream string + cmd := &cobra.Command{ Use: "ingest", Short: "Fetch advisories from OSV/GHSA for an upstream", - RunE: func(*cobra.Command, []string) error { - slog.Info("command not implemented", "command", "ingest") - return errors.New("not implemented") + RunE: func(cmd *cobra.Command, _ []string) error { + if upstream == "" { + return errors.New("--upstream is required") + } + cfg, ok := ConfigFromCommand(cmd) + if !ok { + return errors.New("configuration not loaded") + } + s, err := store.New(cmd.Context(), cfg.DBPath) + if err != nil { + return err + } + defer func() { + if err := s.Close(); err != nil { + slog.Error("failed to close store", "error", err) + } + }() + + summary, err := ingest.RunOSV(cmd.Context(), s.DB(), osv.New(), upstream) + if err != nil { + return err + } + slog.Info( + "ingest complete", + "upstream", upstream, + "advisories_processed", summary.AdvisoriesProcessed, + "new_advisories", summary.NewAdvisories, + "fix_commits_processed", summary.FixCommitsProcessed, + ) + return nil }, } + cmd.Flags().StringVar(&upstream, "upstream", "", "upstream repository name or URL") + + return cmd } diff --git a/internal/cli/init.go b/internal/cli/init.go index a90989b..38a721f 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -16,17 +16,23 @@ package cli import ( "errors" + "fmt" "log/slog" + "os" "github.com/Ozark-Security-Labs/forkguard/internal/store" "github.com/spf13/cobra" ) func newInitCommand() *cobra.Command { - return &cobra.Command{ + var forksPath string + cmd := &cobra.Command{ Use: "init", Short: "Initialize ForkGuard state and load forks.yaml", RunE: func(cmd *cobra.Command, _ []string) error { + if err := validateForksPath(forksPath); err != nil { + return err + } cfg, ok := ConfigFromCommand(cmd) if !ok { return errors.New("configuration not loaded") @@ -40,8 +46,26 @@ func newInitCommand() *cobra.Command { slog.Error("failed to close store", "error", err) } }() - slog.Info("initialized ForkGuard state", "db_path", cfg.DBPath) + slog.Info("initialized ForkGuard state", "db_path", cfg.DBPath, "forks", forksPath) return nil }, } + cmd.Flags().StringVar(&forksPath, "forks", "", "path to forks.yaml (accepted for M2 loading)") + + return cmd +} + +func validateForksPath(path string) error { + if path == "" { + return nil + } + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("validate forks file: %w", err) + } + if info.IsDir() { + return fmt.Errorf("validate forks file: %s is a directory", path) + } + + return nil } diff --git a/internal/config/config.go b/internal/config/config.go index 850257f..fb50910 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,10 +58,7 @@ func Load(cmd *cobra.Command) (*Config, error) { setDefaults(v) bindEnv(v) - configPath, explicitConfig, err := configPathFromCommand(cmd) - if err != nil { - return nil, err - } + configPath, explicitConfig := configPathFromCommand(cmd) if err := readConfigFile(v, configPath, explicitConfig); err != nil { return nil, err } @@ -77,6 +74,7 @@ func Load(cmd *cobra.Command) (*Config, error) { applyChangedStringFlag(cmd, "log-format", &cfg.LogFormat) applyChangedStringFlag(cmd, "log-level", &cfg.LogLevel) + var err error cfg.DBPath, err = ExpandPath(cfg.DBPath) if err != nil { return nil, fmt.Errorf("expand db path: %w", err) @@ -145,17 +143,17 @@ func readConfigFile(v *viper.Viper, path string, explicit bool) error { return nil } -func configPathFromCommand(cmd *cobra.Command) (string, bool, error) { +func configPathFromCommand(cmd *cobra.Command) (string, bool) { flag := findFlag(cmd, "config") if flag == nil { - return DefaultConfigPath, false, nil + return DefaultConfigPath, false } value := flag.Value.String() if value == "" { - return DefaultConfigPath, false, nil + return DefaultConfigPath, false } - return value, flag.Changed, nil + return value, flag.Changed } func applyChangedStringFlag(cmd *cobra.Command, name string, target *string) { diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go new file mode 100644 index 0000000..a0068a6 --- /dev/null +++ b/internal/ingest/ingest.go @@ -0,0 +1,174 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package ingest orchestrates advisory ingestion into the ForkGuard store. +package ingest + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "github.com/Ozark-Security-Labs/forkguard/internal/ingest/normalize" + "github.com/Ozark-Security-Labs/forkguard/internal/ingest/osv" + "github.com/Ozark-Security-Labs/forkguard/internal/store/gen" +) + +// OSVClient is the subset of the OSV client used by ingestion. +type OSVClient interface { + QueryByRepo(ctx context.Context, repoURL string) ([]osv.Vulnerability, error) +} + +// Summary describes the result of an ingest run. +type Summary struct { + AdvisoriesProcessed int + NewAdvisories int + FixCommitsProcessed int +} + +// RunOSV fetches OSV records for an upstream and persists normalized results. +func RunOSV(ctx context.Context, db *sql.DB, client OSVClient, upstreamName string) (Summary, error) { + if strings.TrimSpace(upstreamName) == "" { + return Summary{}, errors.New("upstream is required") + } + + queries := gen.New(db) + upstream, err := getOrCreateUpstream(ctx, queries, upstreamName) + if err != nil { + return Summary{}, err + } + + vulns, err := client.QueryByRepo(ctx, upstream.CloneUrl) + if err != nil { + return Summary{}, fmt.Errorf("query OSV for %s: %w", upstream.CloneUrl, err) + } + + summary := Summary{AdvisoriesProcessed: len(vulns)} + for _, raw := range vulns { + advisory, fixCommits, err := normalize.NormalizeOSV(raw, normalize.Upstream{ + Name: upstream.Name, + CloneURL: upstream.CloneUrl, + }) + if err != nil { + return Summary{}, err + } + + isNew, err := advisoryIsNew(ctx, queries, advisory) + if err != nil { + return Summary{}, err + } + storedAdvisory, err := queries.UpsertAdvisory(ctx, gen.UpsertAdvisoryParams{ + Source: advisory.Source, + SourceID: advisory.SourceID, + Summary: advisory.Summary, + Severity: nullableString(advisory.Severity), + CvssVector: nullableString(advisory.CVSSVector), + PublishedAt: nullableTime(advisory.PublishedAt), + ModifiedAt: nullableTime(advisory.ModifiedAt), + RawJson: advisory.RawJSON, + }) + if err != nil { + return Summary{}, fmt.Errorf("upsert advisory %s: %w", advisory.SourceID, err) + } + if isNew { + summary.NewAdvisories++ + } + if err := queries.LinkAdvisoryToUpstream(ctx, gen.LinkAdvisoryToUpstreamParams{ + AdvisoryID: storedAdvisory.ID, + UpstreamID: upstream.ID, + }); err != nil { + return Summary{}, fmt.Errorf("link advisory %s to upstream: %w", advisory.SourceID, err) + } + + for _, fixCommit := range fixCommits { + if _, err := queries.UpsertFixCommit(ctx, gen.UpsertFixCommitParams{ + AdvisoryID: storedAdvisory.ID, + UpstreamID: upstream.ID, + CommitSha: fixCommit.CommitSHA, + Confidence: fixCommit.Confidence, + }); err != nil { + return Summary{}, fmt.Errorf("upsert fix commit %s: %w", fixCommit.CommitSHA, err) + } + summary.FixCommitsProcessed++ + } + } + + if err := queries.UpdateUpstreamIngestedAt(ctx, gen.UpdateUpstreamIngestedAtParams{ + ID: upstream.ID, + LastIngestedAt: sql.NullTime{Time: time.Now().UTC(), Valid: true}, + }); err != nil { + return Summary{}, fmt.Errorf("update upstream last_ingested_at: %w", err) + } + + return summary, nil +} + +func getOrCreateUpstream(ctx context.Context, queries *gen.Queries, name string) (gen.Upstream, error) { + upstream, err := queries.GetUpstreamByName(ctx, name) + if err == nil { + return upstream, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return gen.Upstream{}, fmt.Errorf("get upstream %s: %w", name, err) + } + + upstream, err = queries.CreateUpstream(ctx, gen.CreateUpstreamParams{ + Name: name, + CloneUrl: CloneURLForUpstream(name), + PackageEcosystem: sql.NullString{}, + PackageName: sql.NullString{}, + }) + if err != nil { + return gen.Upstream{}, fmt.Errorf("create upstream %s: %w", name, err) + } + + return upstream, nil +} + +// CloneURLForUpstream returns a URL suitable for querying and cloning an upstream. +func CloneURLForUpstream(name string) string { + parsed, err := url.Parse(name) + if err == nil && parsed.Scheme != "" { + return name + } + + return "https://" + strings.TrimPrefix(name, "https://") +} + +func advisoryIsNew(ctx context.Context, queries *gen.Queries, advisory normalize.Advisory) (bool, error) { + _, err := queries.GetAdvisoryBySourceID(ctx, gen.GetAdvisoryBySourceIDParams{ + Source: advisory.Source, + SourceID: advisory.SourceID, + }) + if err == nil { + return false, nil + } + if errors.Is(err, sql.ErrNoRows) { + return true, nil + } + + return false, fmt.Errorf("get advisory %s: %w", advisory.SourceID, err) +} + +func nullableString(value string) sql.NullString { + return sql.NullString{String: value, Valid: value != ""} +} + +func nullableTime(value time.Time) sql.NullTime { + return sql.NullTime{Time: value, Valid: !value.IsZero()} +} diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go new file mode 100644 index 0000000..046d9fc --- /dev/null +++ b/internal/ingest/ingest_test.go @@ -0,0 +1,121 @@ +// Copyright 2026 Ozark Security Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ingest + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + "time" + + "github.com/Ozark-Security-Labs/forkguard/internal/ingest/osv" + "github.com/Ozark-Security-Labs/forkguard/internal/store" +) + +func TestRunOSVIdempotent(t *testing.T) { + ctx := context.Background() + s, err := store.New(ctx, filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatalf("store.New() error = %v", err) + } + defer func() { + if err := s.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + }() + + client := fakeOSVClient{vulns: []osv.Vulnerability{sampleVulnerability()}} + first, err := RunOSV(ctx, s.DB(), client, "github.com/openssl/openssl") + if err != nil { + t.Fatalf("RunOSV() first error = %v", err) + } + second, err := RunOSV(ctx, s.DB(), client, "github.com/openssl/openssl") + if err != nil { + t.Fatalf("RunOSV() second error = %v", err) + } + + if first.AdvisoriesProcessed != 1 || first.NewAdvisories != 1 || first.FixCommitsProcessed != 1 { + t.Fatalf("first summary = %+v", first) + } + if second.AdvisoriesProcessed != 1 || second.NewAdvisories != 0 || second.FixCommitsProcessed != 1 { + t.Fatalf("second summary = %+v", second) + } + assertCount(t, s.DB(), "advisories", 1) + assertCount(t, s.DB(), "fix_commits", 1) + assertCount(t, s.DB(), "advisory_upstreams", 1) + + var lastIngested sql.NullTime + if err := s.DB().QueryRow(`SELECT last_ingested_at FROM upstreams WHERE name = ?`, "github.com/openssl/openssl").Scan(&lastIngested); err != nil { + t.Fatalf("query last_ingested_at: %v", err) + } + if !lastIngested.Valid { + t.Fatal("last_ingested_at was not set") + } +} + +func TestCloneURLForUpstream(t *testing.T) { + if got := CloneURLForUpstream("github.com/openssl/openssl"); got != "https://github.com/openssl/openssl" { + t.Fatalf("CloneURLForUpstream() = %q", got) + } + if got := CloneURLForUpstream("https://github.com/openssl/openssl"); got != "https://github.com/openssl/openssl" { + t.Fatalf("CloneURLForUpstream() = %q", got) + } +} + +type fakeOSVClient struct { + vulns []osv.Vulnerability +} + +func (f fakeOSVClient) QueryByRepo(context.Context, string) ([]osv.Vulnerability, error) { + return f.vulns, nil +} + +func sampleVulnerability() osv.Vulnerability { + published := time.Date(2023, 3, 21, 12, 0, 0, 0, time.UTC) + modified := time.Date(2023, 3, 22, 12, 0, 0, 0, time.UTC) + return osv.Vulnerability{ + ID: "CVE-2023-0464", + Summary: "OpenSSL sample vulnerability fixture", + Published: published, + Modified: modified, + Severity: []osv.Severity{{ + Type: "CVSS_V3", + Score: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + }}, + DatabaseSpecific: map[string]any{"severity": "HIGH"}, + Affected: []osv.Affected{{ + Ranges: []osv.Range{{ + Type: "GIT", + Repo: "https://github.com/openssl/openssl", + Events: []osv.RangeEvent{ + {Introduced: "0"}, + {Fixed: "0123456789abcdef0123456789abcdef01234567"}, + }, + }}, + }}, + } +} + +func assertCount(t *testing.T, db *sql.DB, table string, want int64) { + t.Helper() + var got int64 + if err := db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&got); err != nil { + t.Fatalf("count %s: %v", table, err) + } + if got != want { + t.Fatalf("count %s = %d, want %d", table, got, want) + } +} diff --git a/internal/ingest/normalize/normalize.go b/internal/ingest/normalize/normalize.go index c061397..848b58b 100644 --- a/internal/ingest/normalize/normalize.go +++ b/internal/ingest/normalize/normalize.go @@ -56,6 +56,8 @@ type FixCommit struct { } // NormalizeOSV converts an OSV vulnerability into ForkGuard advisory records. +// +//revive:disable-next-line:exported required by the milestone API contract func NormalizeOSV(raw osv.Vulnerability, upstream Upstream) (Advisory, []FixCommit, error) { rawJSON, err := json.Marshal(raw) if err != nil { diff --git a/internal/ingest/osv/client.go b/internal/ingest/osv/client.go index 8c782cd..1da2e6a 100644 --- a/internal/ingest/osv/client.go +++ b/internal/ingest/osv/client.go @@ -20,6 +20,7 @@ import ( "context" cryptorand "crypto/rand" "encoding/json" + "errors" "fmt" "io" "math/big" @@ -96,12 +97,27 @@ func WithBackoff(backoff func(int) time.Duration) Option { // QueryByRepo queries OSV for vulnerabilities associated with a source repo. func (c *Client) QueryByRepo(ctx context.Context, repoURL string) ([]Vulnerability, error) { + vulns, err := c.queryVulnerabilities(ctx, func(pageToken string) any { + return queryRequest{Repo: repoURL, PageToken: pageToken} + }) + if err == nil || !isHTTPStatus(err, http.StatusBadRequest) { + return vulns, err + } + + return c.queryVulnerabilities(ctx, func(pageToken string) any { + return gitPackageQueryRequest{ + Package: Package{Ecosystem: "GIT", Name: repoURL}, + PageToken: pageToken, + } + }) +} + +func (c *Client) queryVulnerabilities(ctx context.Context, payload func(pageToken string) any) ([]Vulnerability, error) { var vulns []Vulnerability pageToken := "" for { - payload := queryRequest{Repo: repoURL, PageToken: pageToken} var response queryResponse - if err := c.postJSON(ctx, "/v1/query", payload, &response); err != nil { + if err := c.postJSON(ctx, "/v1/query", payload(pageToken), &response); err != nil { return nil, err } vulns = append(vulns, response.Vulns...) @@ -221,7 +237,7 @@ func (c *Client) do(req *http.Request) ([]byte, bool, error) { } retry := resp.StatusCode >= 500 - return nil, retry, fmt.Errorf("OSV request failed with status %d: %s", resp.StatusCode, string(body)) + return nil, retry, httpError{StatusCode: resp.StatusCode, Body: string(body)} } func readZipVulnerability(file *zip.File) (Vulnerability, error) { @@ -264,11 +280,30 @@ func retryBackoff(attempt int) time.Duration { return base + time.Duration(jitter.Int64()) } +type httpError struct { + StatusCode int + Body string +} + +func (e httpError) Error() string { + return fmt.Sprintf("OSV request failed with status %d: %s", e.StatusCode, e.Body) +} + +func isHTTPStatus(err error, status int) bool { + var httpErr httpError + return errors.As(err, &httpErr) && httpErr.StatusCode == status +} + type queryRequest struct { Repo string `json:"repo"` PageToken string `json:"page_token,omitempty"` } +type gitPackageQueryRequest struct { + Package Package `json:"package"` + PageToken string `json:"page_token,omitempty"` +} + type queryResponse struct { Vulns []Vulnerability `json:"vulns"` NextPageToken string `json:"next_page_token,omitempty"` diff --git a/internal/ingest/osv/client_test.go b/internal/ingest/osv/client_test.go index a75dea0..ba9a07b 100644 --- a/internal/ingest/osv/client_test.go +++ b/internal/ingest/osv/client_test.go @@ -66,6 +66,40 @@ func TestQueryByRepo(t *testing.T) { } } +func TestQueryByRepoFallsBackToGitPackageQuery(t *testing.T) { + fixture := loadFixture(t) + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if requests == 1 { + http.Error(w, "invalid query", http.StatusBadRequest) + return + } + var request gitPackageQueryRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("Decode() error = %v", err) + } + if request.Package.Ecosystem != "GIT" || request.Package.Name != "https://github.com/openssl/openssl" { + t.Fatalf("package query = %+v", request.Package) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"vulns":[` + string(fixture) + `]}`)) + })) + defer server.Close() + + client := New(WithBaseURL(server.URL), WithBackoff(noBackoff)) + vulns, err := client.QueryByRepo(context.Background(), "https://github.com/openssl/openssl") + if err != nil { + t.Fatalf("QueryByRepo() error = %v", err) + } + if requests != 2 { + t.Fatalf("requests = %d, want 2", requests) + } + if len(vulns) != 1 { + t.Fatalf("len(vulns) = %d, want 1", len(vulns)) + } +} + func TestQueryByRepoRetriesServerErrors(t *testing.T) { fixture := loadFixture(t) var requests int diff --git a/internal/store/store.go b/internal/store/store.go index 87796c0..0b0bda0 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -28,7 +28,7 @@ import ( "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/sqlite" "github.com/golang-migrate/migrate/v4/source/iofs" - _ "modernc.org/sqlite" + _ "modernc.org/sqlite" // Register the pure-Go SQLite database/sql driver. ) //go:embed migrations/*.sql From 03c853a0fc67dae0ef4a8eb54a6db61f20257b63 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:19:23 -0500 Subject: [PATCH 12/18] Fix #8: Make sqlc generation self-contained Signed-off-by: Brian Corder --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5482e2f..fc06744 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ build: go build ./cmd/forkguard generate: - sqlc generate + go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 generate ./scripts/add-go-license-headers.sh internal/store/gen/*.go gofmt -w internal/store/gen/*.go From e7cc98508f2f3b5b9c8b21d67372ff7d90aaa952 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:28:43 -0500 Subject: [PATCH 13/18] Fix #2: Enable Go security automation Signed-off-by: Brian Corder --- .github/dependabot.yml | 11 +++++++++++ .github/workflows/codeql.yml | 14 +++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d4a9f52..2190322 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,3 +11,14 @@ updates: labels: - dependencies - github-actions + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + day: monday + time: "09:45" + timezone: America/Chicago + open-pull-requests-limit: 10 + labels: + - dependencies + - go diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a8324a5..f352488 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,11 +12,15 @@ permissions: security-events: write jobs: - codeql-ready: + analyze: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - name: CodeQL bootstrap placeholder - run: | - echo "CodeQL analysis will be enabled when implementation source lands." - test -f README.md + - uses: github/codeql-action/init@fee9466b8957867761f2d78f922ab084e3e2dd17 + with: + languages: go + - name: Build + run: go build ./... + - uses: github/codeql-action/analyze@fee9466b8957867761f2d78f922ab084e3e2dd17 + with: + category: "/language:go" From 4ec46ff9a9909ee5b8852c1c400107e7619bb6c6 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:28:47 -0500 Subject: [PATCH 14/18] Fix #9: Harden OSV client response handling Signed-off-by: Brian Corder --- internal/ingest/osv/client.go | 50 ++++++++++++++++++++++++------ internal/ingest/osv/client_test.go | 20 ++++++++++++ 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/internal/ingest/osv/client.go b/internal/ingest/osv/client.go index 1da2e6a..8e00e9b 100644 --- a/internal/ingest/osv/client.go +++ b/internal/ingest/osv/client.go @@ -35,6 +35,10 @@ const ( defaultAPIBaseURL = "https://api.osv.dev" defaultStorageBaseURL = "https://osv-vulnerabilities.storage.googleapis.com" maxAttempts = 3 + maxHTTPBodyBytes = 256 << 20 + maxHTTPErrorBytes = 4 << 10 + maxZipFiles = 100000 + maxZipJSONBytes = 10 << 20 ) // Option configures an OSV client. @@ -145,6 +149,9 @@ func (c *Client) DownloadEcosystem(ctx context.Context, ecosystem string) ([]Vul if err != nil { return nil, fmt.Errorf("open OSV ecosystem zip: %w", err) } + if len(reader.File) > maxZipFiles { + return nil, fmt.Errorf("OSV ecosystem zip contains %d files, limit is %d", len(reader.File), maxZipFiles) + } vulns := make([]Vulnerability, 0, len(reader.File)) for _, file := range reader.File { @@ -228,33 +235,58 @@ func (c *Client) do(req *http.Request) ([]byte, bool, error) { } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, false, fmt.Errorf("read OSV response: %w", err) - } - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return body, false, nil + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, readErr := readLimited(resp.Body, maxHTTPErrorBytes) + if readErr != nil { + return nil, false, readErr + } + retry := resp.StatusCode >= 500 + return nil, retry, httpError{StatusCode: resp.StatusCode, Body: sanitizeRemoteBody(string(body))} } - retry := resp.StatusCode >= 500 - return nil, retry, httpError{StatusCode: resp.StatusCode, Body: string(body)} + body, err := readLimited(resp.Body, maxHTTPBodyBytes) + if err != nil { + return nil, false, err + } + return body, false, nil } func readZipVulnerability(file *zip.File) (Vulnerability, error) { + if file.UncompressedSize64 > maxZipJSONBytes { + return Vulnerability{}, fmt.Errorf("OSV zip entry %s is too large", file.Name) + } r, err := file.Open() if err != nil { return Vulnerability{}, fmt.Errorf("open %s from OSV zip: %w", file.Name, err) } defer r.Close() + limited := io.LimitReader(r, maxZipJSONBytes+1) var vuln Vulnerability - if err := json.NewDecoder(r).Decode(&vuln); err != nil { + if err := json.NewDecoder(limited).Decode(&vuln); err != nil { return Vulnerability{}, fmt.Errorf("decode %s from OSV zip: %w", file.Name, err) } return vuln, nil } +func readLimited(r io.Reader, limit int64) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(r, limit+1)) + if err != nil { + return nil, fmt.Errorf("read OSV response: %w", err) + } + if int64(len(body)) > limit { + return nil, fmt.Errorf("OSV response exceeds %d byte limit", limit) + } + return body, nil +} + +func sanitizeRemoteBody(body string) string { + body = strings.NewReplacer("\r", " ", "\n", " ", "\t", " ").Replace(body) + body = strings.Join(strings.Fields(body), " ") + return body +} + func sleep(ctx context.Context, duration time.Duration) error { if duration <= 0 { return nil diff --git a/internal/ingest/osv/client_test.go b/internal/ingest/osv/client_test.go index ba9a07b..fb2308b 100644 --- a/internal/ingest/osv/client_test.go +++ b/internal/ingest/osv/client_test.go @@ -155,6 +155,26 @@ func TestDownloadEcosystem(t *testing.T) { } } +func TestDownloadEcosystemRejectsOversizedJSONEntry(t *testing.T) { + archive := makeZip(t, "huge.json", bytes.Repeat([]byte(" "), maxZipJSONBytes+1)) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(archive) + })) + defer server.Close() + + client := New(WithStorageBaseURL(server.URL), WithBackoff(noBackoff)) + if _, err := client.DownloadEcosystem(context.Background(), "Go"); err == nil { + t.Fatal("DownloadEcosystem() error = nil, want oversized entry error") + } +} + +func TestSanitizeRemoteBody(t *testing.T) { + got := sanitizeRemoteBody("bad\nbody\twith spaces") + if got != "bad body with spaces" { + t.Fatalf("sanitizeRemoteBody() = %q", got) + } +} + func loadFixture(t *testing.T) []byte { t.Helper() path := filepath.Join("..", "..", "..", "tests", "fixtures", "advisories", "sample-cve-osv.json") From 707689625be554dfda65d5ebfbb2d7c93757a20d Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:28:51 -0500 Subject: [PATCH 15/18] Fix #10: Derive severity from CVSS vectors Signed-off-by: Brian Corder --- internal/ingest/normalize/normalize.go | 117 ++++++++++++++++++++ internal/ingest/normalize/normalize_test.go | 13 +++ 2 files changed, 130 insertions(+) diff --git a/internal/ingest/normalize/normalize.go b/internal/ingest/normalize/normalize.go index 848b58b..b92ec14 100644 --- a/internal/ingest/normalize/normalize.go +++ b/internal/ingest/normalize/normalize.go @@ -17,7 +17,9 @@ package normalize import ( "encoding/json" "fmt" + "math" "net/url" + "strconv" "strings" "time" @@ -108,6 +110,9 @@ func extractSeverity(raw osv.Vulnerability) string { if value, ok := raw.DatabaseSpecific["Severity"].(string); ok { return value } + if rating := cvssSeverity(extractCVSSVector(raw)); rating != "" { + return rating + } return "" } @@ -120,6 +125,118 @@ func extractCVSSVector(raw osv.Vulnerability) string { return "" } +func cvssSeverity(vector string) string { + score, ok := cvssV3BaseScore(vector) + if !ok { + return "" + } + switch { + case score == 0: + return "NONE" + case score < 4: + return "LOW" + case score < 7: + return "MEDIUM" + case score < 9: + return "HIGH" + default: + return "CRITICAL" + } +} + +func cvssV3BaseScore(vector string) (float64, bool) { + if !strings.HasPrefix(vector, "CVSS:3.") { + return 0, false + } + metrics := map[string]string{} + for _, part := range strings.Split(vector, "/") { + name, value, ok := strings.Cut(part, ":") + if ok { + metrics[name] = value + } + } + + av, ok := cvssMetric(metrics, "AV", map[string]float64{"N": 0.85, "A": 0.62, "L": 0.55, "P": 0.2}) + if !ok { + return 0, false + } + ac, ok := cvssMetric(metrics, "AC", map[string]float64{"L": 0.77, "H": 0.44}) + if !ok { + return 0, false + } + ui, ok := cvssMetric(metrics, "UI", map[string]float64{"N": 0.85, "R": 0.62}) + if !ok { + return 0, false + } + scope := metrics["S"] + pr, ok := cvssPrivilegesRequired(metrics["PR"], scope) + if !ok { + return 0, false + } + confidentiality, ok := cvssMetric(metrics, "C", impactValues()) + if !ok { + return 0, false + } + integrity, ok := cvssMetric(metrics, "I", impactValues()) + if !ok { + return 0, false + } + availability, ok := cvssMetric(metrics, "A", impactValues()) + if !ok { + return 0, false + } + + impactSubScore := 1 - ((1 - confidentiality) * (1 - integrity) * (1 - availability)) + var impact float64 + switch scope { + case "U": + impact = 6.42 * impactSubScore + case "C": + impact = 7.52*(impactSubScore-0.029) - 3.25*math.Pow(impactSubScore-0.02, 15) + default: + return 0, false + } + if impact <= 0 { + return 0, true + } + + exploitability := 8.22 * av * ac * pr * ui + if scope == "C" { + return roundUp1Decimal(math.Min(1.08*(impact+exploitability), 10)), true + } + return roundUp1Decimal(math.Min(impact+exploitability, 10)), true +} + +func cvssMetric(metrics map[string]string, name string, values map[string]float64) (float64, bool) { + value, ok := values[metrics[name]] + return value, ok +} + +func cvssPrivilegesRequired(value string, scope string) (float64, bool) { + if value == "N" { + return 0.85, true + } + if scope == "U" { + return cvssMetric(map[string]string{"PR": value}, "PR", map[string]float64{"L": 0.62, "H": 0.27}) + } + if scope == "C" { + return cvssMetric(map[string]string{"PR": value}, "PR", map[string]float64{"L": 0.68, "H": 0.5}) + } + return 0, false +} + +func impactValues() map[string]float64 { + return map[string]float64{"H": 0.56, "L": 0.22, "N": 0} +} + +func roundUp1Decimal(value float64) float64 { + rounded, err := strconv.ParseFloat(fmt.Sprintf("%.1f", math.Ceil(value*10)/10), 64) + if err != nil { + return value + } + return rounded +} + func repoMatches(repo string, upstream Upstream) bool { candidate := canonicalRepo(repo) if candidate == "" { diff --git a/internal/ingest/normalize/normalize_test.go b/internal/ingest/normalize/normalize_test.go index ac25301..5e1016f 100644 --- a/internal/ingest/normalize/normalize_test.go +++ b/internal/ingest/normalize/normalize_test.go @@ -65,6 +65,19 @@ func TestNormalizeOSVSingleGitRange(t *testing.T) { } } +func TestNormalizeOSVSeverityFallsBackToCVSSVector(t *testing.T) { + raw := sampleVulnerability() + raw.DatabaseSpecific = nil + + advisory, _, err := NormalizeOSV(raw, sampleUpstream()) + if err != nil { + t.Fatalf("NormalizeOSV() error = %v", err) + } + if advisory.Severity != "CRITICAL" { + t.Fatalf("Severity = %q, want CRITICAL", advisory.Severity) + } +} + func TestNormalizeOSVMultipleGitRanges(t *testing.T) { raw := sampleVulnerability() raw.Affected[0].Ranges = append(raw.Affected[0].Ranges, osv.Range{ From a5dbbf6b50692cd0d47239a73f4d16c53011d1b7 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:28:55 -0500 Subject: [PATCH 16/18] Fix #11: Make OSV ingest transactional Signed-off-by: Brian Corder --- internal/ingest/ingest.go | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index a0068a6..dc7e206 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -47,15 +47,23 @@ func RunOSV(ctx context.Context, db *sql.DB, client OSVClient, upstreamName stri return Summary{}, errors.New("upstream is required") } - queries := gen.New(db) - upstream, err := getOrCreateUpstream(ctx, queries, upstreamName) + queryURL := CloneURLForUpstream(upstreamName) + vulns, err := client.QueryByRepo(ctx, queryURL) if err != nil { - return Summary{}, err + return Summary{}, fmt.Errorf("query OSV for %s: %w", queryURL, err) } - vulns, err := client.QueryByRepo(ctx, upstream.CloneUrl) + tx, err := db.BeginTx(ctx, nil) if err != nil { - return Summary{}, fmt.Errorf("query OSV for %s: %w", upstream.CloneUrl, err) + return Summary{}, fmt.Errorf("begin ingest transaction: %w", err) + } + defer func() { + _ = tx.Rollback() + }() + queries := gen.New(db).WithTx(tx) + upstream, err := getOrCreateUpstream(ctx, queries, upstreamName) + if err != nil { + return Summary{}, err } summary := Summary{AdvisoriesProcessed: len(vulns)} @@ -114,6 +122,9 @@ func RunOSV(ctx context.Context, db *sql.DB, client OSVClient, upstreamName stri }); err != nil { return Summary{}, fmt.Errorf("update upstream last_ingested_at: %w", err) } + if err := tx.Commit(); err != nil { + return Summary{}, fmt.Errorf("commit ingest transaction: %w", err) + } return summary, nil } From 8efdd1caaf3ae18263a09726016a14d50d3b075f Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:28:58 -0500 Subject: [PATCH 17/18] Fix #8: Format license header helper Signed-off-by: Brian Corder --- scripts/add-go-license-headers.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/add-go-license-headers.sh b/scripts/add-go-license-headers.sh index 9037cdd..e776133 100755 --- a/scripts/add-go-license-headers.sh +++ b/scripts/add-go-license-headers.sh @@ -17,12 +17,12 @@ header='// Copyright 2026 Ozark Security Labs ' for file in "$@"; do - if [[ -f "$file" ]] && ! grep -q "Apache License" "$file"; then - tmp=$(mktemp) - { - printf '%s\n' "$header" - cat "$file" - } > "$tmp" - mv "$tmp" "$file" - fi + if [[ -f "$file" ]] && ! grep -q "Apache License" "$file"; then + tmp=$(mktemp) + { + printf '%s\n' "$header" + cat "$file" + } >"$tmp" + mv "$tmp" "$file" + fi done From 8c6133db0dc0cffe0bf33de21b40e2b788e4f2e9 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:32:20 -0500 Subject: [PATCH 18/18] Fix #2: Prevent security smoke check self-match Signed-off-by: Brian Corder --- .github/workflows/security.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 35a40ff..4d382eb 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -17,4 +17,5 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - name: Secret pattern smoke check run: | - ! git grep -nE "(ghp_|github_pat_|sk-[A-Za-z0-9])" + pattern='(ghp_|github_pat_|sk-[A-Za-z0-9])' + ! git grep -nE "$pattern" -- ':!docs/**' ':!.github/workflows/security.yml'