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/.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/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/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/.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/.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" 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' 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/.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..fc06744 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +.PHONY: build generate lint vet test clean + +build: + go build ./cmd/forkguard + +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 + +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/ 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. 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..663852f --- /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 ( + "log/slog" + "os" + + "github.com/Ozark-Security-Labs/forkguard/internal/cli" +) + +func main() { + if err := cli.Execute(); err != nil { + slog.Error(err.Error()) + 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..ca0d7a6 --- /dev/null +++ b/go.mod @@ -0,0 +1,43 @@ +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 + 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.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 new file mode 100644 index 0000000..9b44eb8 --- /dev/null +++ b/go.sum @@ -0,0 +1,127 @@ +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/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= +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/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= +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/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= +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= +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/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..e6bbde0 --- /dev/null +++ b/internal/cli/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 cli contains the ForkGuard command-line interface. +package cli diff --git a/internal/cli/ingest.go b/internal/cli/ingest.go new file mode 100644 index 0000000..385584d --- /dev/null +++ b/internal/cli/ingest.go @@ -0,0 +1,67 @@ +// 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" + "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 { + var upstream string + cmd := &cobra.Command{ + Use: "ingest", + Short: "Fetch advisories from OSV/GHSA for an upstream", + 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 new file mode 100644 index 0000000..38a721f --- /dev/null +++ b/internal/cli/init.go @@ -0,0 +1,71 @@ +// 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" + "fmt" + "log/slog" + "os" + + "github.com/Ozark-Security-Labs/forkguard/internal/store" + "github.com/spf13/cobra" +) + +func newInitCommand() *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") + } + 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, "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/cli/report.go b/internal/cli/report.go new file mode 100644 index 0000000..cbd72dc --- /dev/null +++ b/internal/cli/report.go @@ -0,0 +1,33 @@ +// 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" + "log/slog" + + "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 { + slog.Info("command not implemented", "command", "report") + return errors.New("not implemented") + }, + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..a8dfcd6 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,92 @@ +// 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 ( + "context" + "fmt" + "io" + + "github.com/Ozark-Security-Labs/forkguard/internal/config" + "github.com/Ozark-Security-Labs/forkguard/internal/logging" + "github.com/spf13/cobra" +) + +type rootOptions struct { + configPath string + logFormat string + logLevel string +} + +type configContextKey struct{} + +// 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, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load(cmd) + if err != nil { + return err + } + logging.Setup(cfg.LogFormat, cfg.LogLevel) + 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", 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")) + + cmd.AddCommand( + newInitCommand(), + newIngestCommand(), + newScanCommand(), + newReportCommand(), + newVersionCommand(), + ) + + 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 + } +} + +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..1392dad --- /dev/null +++ b/internal/cli/scan.go @@ -0,0 +1,33 @@ +// 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" + "log/slog" + + "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 { + slog.Info("command not implemented", "command", "scan") + 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") + }, + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..fb50910 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,180 @@ +// 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 := configPathFromCommand(cmd) + 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) + + var err error + 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) { + flag := findFlag(cmd, "config") + if flag == nil { + return DefaultConfigPath, false + } + value := flag.Value.String() + if value == "" { + return DefaultConfigPath, false + } + + return value, flag.Changed +} + +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) + } +} 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/ingest.go b/internal/ingest/ingest.go new file mode 100644 index 0000000..dc7e206 --- /dev/null +++ b/internal/ingest/ingest.go @@ -0,0 +1,185 @@ +// 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") + } + + queryURL := CloneURLForUpstream(upstreamName) + vulns, err := client.QueryByRepo(ctx, queryURL) + if err != nil { + return Summary{}, fmt.Errorf("query OSV for %s: %w", queryURL, err) + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + 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)} + 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) + } + if err := tx.Commit(); err != nil { + return Summary{}, fmt.Errorf("commit ingest transaction: %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/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/normalize/normalize.go b/internal/ingest/normalize/normalize.go new file mode 100644 index 0000000..b92ec14 --- /dev/null +++ b/internal/ingest/normalize/normalize.go @@ -0,0 +1,274 @@ +// 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" + "math" + "net/url" + "strconv" + "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. +// +//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 { + 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 + } + if rating := cvssSeverity(extractCVSSVector(raw)); rating != "" { + return rating + } + 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 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 == "" { + 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..5e1016f --- /dev/null +++ b/internal/ingest/normalize/normalize_test.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" + "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 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{ + 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"}, + }, + }}, + }}, + } +} diff --git a/internal/ingest/osv/client.go b/internal/ingest/osv/client.go new file mode 100644 index 0000000..8e00e9b --- /dev/null +++ b/internal/ingest/osv/client.go @@ -0,0 +1,342 @@ +// 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" + "errors" + "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 + maxHTTPBodyBytes = 256 << 20 + maxHTTPErrorBytes = 4 << 10 + maxZipFiles = 100000 + maxZipJSONBytes = 10 << 20 +) + +// 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) { + 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 { + var response queryResponse + if err := c.postJSON(ctx, "/v1/query", payload(pageToken), &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) + } + 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 { + 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() + + 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))} + } + + 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(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 + } + 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<"$tmp" + mv "$tmp" "$file" + fi +done diff --git a/scripts/build-test-repos/.gitkeep b/scripts/build-test-repos/.gitkeep new file mode 100644 index 0000000..e69de29 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 diff --git a/tests/fixtures/advisories/.gitkeep b/tests/fixtures/advisories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/advisories/sample-cve-osv.json b/tests/fixtures/advisories/sample-cve-osv.json new file mode 100644 index 0000000..afd7e10 --- /dev/null +++ b/tests/fixtures/advisories/sample-cve-osv.json @@ -0,0 +1,39 @@ +{ + "id": "CVE-2023-0464", + "summary": "OpenSSL sample vulnerability fixture", + "details": "Sample OSV record used for ForkGuard tests.", + "aliases": ["GHSA-sample-0000"], + "modified": "2023-03-22T12:00:00Z", + "published": "2023-03-21T12:00:00Z", + "affected": [ + { + "package": { + "ecosystem": "GIT", + "name": "https://github.com/openssl/openssl" + }, + "ranges": [ + { + "type": "GIT", + "repo": "https://github.com/openssl/openssl", + "events": [ + {"introduced": "0"}, + {"fixed": "0123456789abcdef0123456789abcdef01234567"} + ] + } + ] + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://www.openssl.org/news/secadv/20230322.txt" + } + ], + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + } + ], + "schema_version": "1.6.0" +} 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