├── .devcontainer └── devcontainer.json ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── go-quality.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── .vscode ├── launch.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── _TESTDATA_ ├── ignore │ └── bomber.ignore └── sbom │ ├── bom-2.8.0.json │ ├── bom-2.8.1.json │ ├── bomber.cyclonedx.1.6.json │ ├── bomber.spdx.json │ ├── bomber.syft.json │ ├── cargo-valid-bom-1.3.json │ ├── erroring-ossindex-sbom.json │ ├── expression-license.json │ ├── images │ ├── dependencytrack-apiserver-spdx.json │ ├── dependencytrack-apiserver.cyclonedx.json │ ├── dependencytrack-apiserver.syft.json │ ├── ubuntu-latest.cyclonedx.json │ └── ubuntu-latest.spdx.json │ ├── jena-kafka-1.4.0-SNAPSHOT-bom.json │ ├── juiceshop.cyclonedx.json │ ├── marketplace.json │ ├── merged │ ├── merged.json │ ├── sbom_specver1.5.json │ └── sbom_specver1.6.json │ ├── railsgoat.cyclonedx.json │ └── small.cyclonedx.json ├── cmd ├── root.go └── scan.go ├── doc └── providers │ ├── github.md │ ├── ossindex.md │ ├── osv.md │ └── snyk.md ├── enrichers ├── enrichmentfactory.go ├── enrichmentfactory_test.go ├── epss │ ├── epss.go │ └── epss_test.go └── openai │ └── openai.go ├── filters ├── ignore.go ├── ignore_test.go ├── purl.go └── purl_test.go ├── formats ├── cyclonedx │ ├── cyclonedx.go │ └── cyclonedx_test.go ├── spdx │ ├── spdx.go │ └── spdx_test.go └── syft │ ├── syft.go │ └── syft_test.go ├── go.mod ├── go.sum ├── img ├── bomber-example.png ├── bomber-html-header.png ├── bomber-html.png ├── bomber-json.png ├── bomber-readme-logo.png ├── bomber-social-banner.png ├── bomber128x128.png ├── bomber512x512.png ├── providers │ ├── banner.png │ ├── github.png │ ├── osv.png │ ├── snyk.png │ └── sonatype.png └── sponsors │ └── zero-logo.png ├── lib ├── loader.go ├── loader_test.go ├── scanner.go ├── scanner_test.go ├── util.go └── util_test.go ├── main.go ├── models ├── constants.go ├── interfaces.go └── structs.go ├── providers ├── gad │ ├── gad.go │ └── gad_test.go ├── ossindex │ ├── OSSIndex.go │ └── OSSIndex_test.go ├── osv │ ├── osv.go │ └── osv_test.go ├── providerfactory.go ├── providerfactory_test.go └── snyk │ ├── jsonapi.go │ ├── orgid.go │ ├── orgid_test.go │ ├── snyk.go │ ├── snyk_test.go │ ├── testdata │ ├── snyk_package_issues_response.json │ └── snyk_self_response.json │ ├── vulns.go │ └── vulns_test.go └── renderers ├── ai ├── ai.go └── ai_test.go ├── html ├── html.go └── html_test.go ├── json ├── json.go └── json_test.go ├── jsonfile └── jsonfile.go ├── md ├── md.go └── md_test.go ├── rendererfactory.go ├── rendererfactory_test.go └── stdout ├── stdout.go └── stdout_test.go /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bomber", 3 | "image": "mcr.microsoft.com/devcontainers/go:1-1.22-bookworm", 4 | "features": { 5 | "ghcr.io/devcontainers-contrib/features/starship:1": {}, 6 | "ghcr.io/azutake/devcontainer-features/go-packages-install:0": { 7 | "packages": [ 8 | "github.com/jandelgado/gcov2lcov@latest", 9 | "github.com/kisielk/errcheck@latest", 10 | "github.com/fzipp/gocyclo/cmd/gocyclo@latest", 11 | "golang.org/x/vuln/cmd/govulncheck@latest", 12 | "honnef.co/go/tools/cmd/staticcheck@latest" 13 | ] 14 | }, 15 | "ghcr.io/dasiths/devcontainer-features/syft:1": {} 16 | }, 17 | "customizations": { 18 | "vscode": { 19 | "settings": { 20 | "editor.fontFamily": "'0xProto Nerd Font','Courier New', monospace", 21 | "terminal.integrated.fontFamily": "'0xProto Nerd Font','Courier New', monospace", 22 | "notebook.output.fontFamily" : "'0xProto Nerd Font','Courier New', monospace", 23 | "explorer.openEditors.sortOrder": "alphabetical", 24 | "explorer.openEditors.minVisible": 0, 25 | "go.buildTags": "", 26 | "go.toolsEnvVars": { 27 | "CGO_ENABLED": "0" 28 | }, 29 | "go.useLanguageServer": true, 30 | "go.testEnvVars": { 31 | "CGO_ENABLED": "1" 32 | }, 33 | "go.testFlags": [ 34 | "-v", 35 | "-race" 36 | ], 37 | "go.testTimeout": "10s", 38 | "go.coverOnSingleTest": true, 39 | "go.coverOnSingleTestFile": true, 40 | "go.coverOnTestPackage": true, 41 | "go.lintTool": "golangci-lint", 42 | "go.lintOnSave": "package", 43 | "[go]": { 44 | "editor.codeActionsOnSave": { 45 | "source.organizeImports": "always" 46 | } 47 | }, 48 | "window.menuBarVisibility": "classic", 49 | "workbench.activityBar.location": "top", 50 | "debug.toolBarLocation": "docked", 51 | "workbench.colorTheme": "Default Light Modern", 52 | "editor.useTabStops": true, 53 | "editor.formatOnSave": true, 54 | "editor.formatOnPaste": true, 55 | "git.autofetch": true, 56 | "[markdown]": { 57 | "editor.defaultFormatter": "esbenp.prettier-vscode" 58 | }, 59 | "markiscodecoverage.coverageThreshold": 95, 60 | "markiscodecoverage.enableOnStartup": true, 61 | "markiscodecoverage.searchCriteria": "*.lcov*" 62 | }, 63 | "extensions": [ 64 | "ms-vscode.go", 65 | "golang.go", 66 | "github.vscode-pull-request-github", 67 | "github.vscode-github-actions", 68 | "aleksandra.go-group-imports", 69 | "oderwat.indent-rainbow", 70 | "quicktype.quicktype", 71 | "jebbs.plantuml", 72 | "foxundermoon.shell-format", 73 | "ahebrank.yaml2json", 74 | "Github.copilot", 75 | "markis.code-coverage", 76 | "Gruntfuggly.todo-tree", 77 | "esbenp.prettier-vscode", 78 | "Tyriar.luna-paint" 79 | ] 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @devops-kung-fu/the-incredibles -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: devops-kung-fu 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: /.github/workflows 5 | schedule: 6 | interval: daily 7 | time: "05:00" 8 | timezone: US/Pacific 9 | - package-ecosystem: gomod 10 | directory: / 11 | schedule: 12 | interval: daily 13 | time: "05:00" 14 | timezone: US/Pacific 15 | -------------------------------------------------------------------------------- /.github/workflows/go-quality.yml: -------------------------------------------------------------------------------- 1 | name: Go Quality Checks 2 | on: push 3 | jobs: 4 | tests: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout 8 | uses: actions/checkout@v4 9 | - name: Setup Go 10 | uses: actions/setup-go@v5 11 | with: 12 | go-version: "1.22" 13 | - name: Install Dependencies 14 | run: | 15 | go version 16 | go install honnef.co/go/tools/cmd/staticcheck@latest 17 | go install github.com/fzipp/gocyclo/cmd/gocyclo@latest 18 | - name: Test 19 | run: | 20 | go test -v -coverprofile=coverage.out ./... 21 | go tool cover -func=coverage.out 22 | - name: CodeCov 23 | run: bash <(curl -s https://codecov.io/bash) 24 | env: 25 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 26 | - name: Build 27 | run: go build 28 | - name: Vet 29 | run: go vet -v 30 | # - 31 | # name: staticcheck 32 | # run: staticcheck -f stylish -checks all ./... 33 | - name: gocyclo 34 | run: gocyclo . 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | - name: Set up Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: "1.23" 23 | check-latest: true 24 | - run: go version 25 | - name: Generate SBOM 26 | uses: anchore/sbom-action@v0 27 | with: 28 | artifact-name: bomber.cyclonedx.json 29 | path: . 30 | format: cyclonedx-json 31 | - name: Release SBOM 32 | uses: anchore/sbom-action/publish-sbom@v0 33 | with: 34 | sbom-artifact-match: ".*\\.cyclonedx.json$" 35 | - name: GoReleaser Action 36 | uses: goreleaser/goreleaser-action@v5.1.0 37 | with: 38 | version: ${{ env.GITHUB_REF_NAME }} 39 | args: release --clean 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.PUBLISHER_TOKEN }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | .DS_Store 18 | 19 | bomber 20 | 21 | *.env 22 | 23 | /rest 24 | 25 | *-results.html 26 | coverage.html 27 | coverage.lcov 28 | 29 | *.log 30 | 31 | __debug_bin* 32 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: bomber 2 | 3 | builds: 4 | - binary: bomber 5 | env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - darwin 9 | - linux 10 | - windows 11 | goarch: 12 | - amd64 13 | - arm64 14 | 15 | release: 16 | prerelease: auto 17 | 18 | universal_binaries: 19 | - replace: true 20 | 21 | brews: 22 | - name: bomber 23 | homepage: "https://github.com/devops-kung-fu/bomber" 24 | tap: 25 | owner: devops-kung-fu 26 | name: homebrew-tap 27 | commit_author: 28 | name: dkfm 29 | email: admin@dkfm.io 30 | 31 | checksum: 32 | name_template: "checksums.txt" 33 | 34 | nfpms: 35 | - maintainer: DevOps Kung Fu Mafia 36 | description: Scans SBOMs for security vulnerabilities. 37 | homepage: https://github.com/devops-kung-fu/bomber 38 | license: MPL 39 | formats: 40 | - deb 41 | - rpm 42 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Afero", 4 | "AIAPI", 5 | "anchore", 6 | "briandowns", 7 | "codecov", 8 | "Conda", 9 | "coverprofile", 10 | "cpes", 11 | "CRAN", 12 | "cves", 13 | "cyclomatic", 14 | "cyclonedx", 15 | "Distro", 16 | "DKFM", 17 | "dpkg", 18 | "DXJSON", 19 | "DXXML", 20 | "Encricher", 21 | "Epss", 22 | "errcheck", 23 | "exitcode", 24 | "gofmt", 25 | "gomod", 26 | "gookit", 27 | "Hookz", 28 | "ignoretests", 29 | "incredibles", 30 | "Infof", 31 | "jarcoal", 32 | "jedib", 33 | "JSONAPI", 34 | "kirinlabs", 35 | "kisielk", 36 | "novulns", 37 | "openai", 38 | "OSSINDEX", 39 | "packageurl", 40 | "Packagist", 41 | "rekor", 42 | "sbom", 43 | "sboms", 44 | "Smashicons", 45 | "snyk", 46 | "Sonatype", 47 | "SPDXID", 48 | "stretchr", 49 | "structs", 50 | "Syft", 51 | "synk", 52 | "Tabbedf", 53 | "unindexed", 54 | "vuln", 55 | "vulns", 56 | "Warningf" 57 | ], 58 | "aws.codeWhisperer.shareCodeWhispererContentWithAWS": false, 59 | "terminal.integrated.customGlyphs": true, 60 | "terminal.integrated.fontFamily": "'0xProto Nerd Font','Courier New', monospace", 61 | "editor.fontFamily": "'0xProto Nerd Font', 'Droid Sans Mono', 'monospace', monospace" 62 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to bomber 2 | 3 | ## We Develop with Github 4 | 5 | We use github to host code, to track issues and feature requests, as well as accept pull requests. 6 | 7 | ## Use GPG to Sign Your Commits 8 | 9 | Only pull requests that have been signed will be accepted. For more information on setting up a GPG key for your Github account see the instructions [here](https://help.github.com/en/articles/managing-commit-signature-verification). 10 | 11 | ## Contributing Code 12 | 13 | All Code Changes Happen Through Pull Requests. Pull requests are the best way to propose changes to the codebase. We actively welcome your pull requests and review regularly. We practice a single trunk development method. 14 | 15 | - Fork the repo and create your branch from main. 16 | - All code requires test coverage. 100% coverage is the target Add new or modify existing tests. 17 | - If you've changed APIs, update the documentation. 18 | - Ensure the tests pass. 19 | - Make sure your code lints (go) 20 | - Create a pull request. 21 | 22 | ## Licensing Notes 23 | 24 | Any contributions you make will be under the MIT Software License. When you submit code changes, your submissions are understood to be under the same MIT License that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Development 27 | 28 | ### Overview 29 | 30 | In order to use contribute and participate in the development of ```bomber``` you'll need to have an updated Go environment. Before you start, please view the [Contributing](CONTRIBUTING.md) and [Code of Conduct](CODE_OF_CONDUCT.md) files in this repository. 31 | 32 | ### Prerequisites 33 | 34 | This project makes use of [DKFM](https://github.com/devops-kung-fu) tools such as [Hookz](https://github.com/devops-kung-fu/hookz), [Hinge](https://github.com/devops-kung-fu/hinge), and other open source tooling. Install these tools with the following commands: 35 | 36 | ``` bash 37 | go install github.com/devops-kung-fu/hookz@latest 38 | go install github.com/devops-kung-fu/hinge@latest 39 | go install github.com/kisielk/errcheck@latest 40 | go install golang.org/x/lint/golint@latest 41 | go install github.com/fzipp/gocyclo@latest 42 | ``` 43 | ### Getting Started 44 | 45 | Once you have installed [Hookz](https://github.com/devops-kung-fu/hookz) and have cloned this repository, execute the following in the root directory: 46 | 47 | ``` bash 48 | hookz init --verbose --debug --verbose-output 49 | ``` 50 | This will configure the ```pre-commit``` hooks to check code quality, tests, update all dependencies, etc. before code gets committed to the remote repository. 51 | 52 | ### Debugging 53 | 54 | The project is set up to work really well with [Visual Studio Code](https://code.visualstudio.com). Once you open the ```bomber``` folder in Visual Studio Code, go ahead and use the debugger to run any one of the pre-set configurations. They are all hooked into the test SBOM's that come with the source code. 55 | 56 | ### Building 57 | 58 | Use the [Makefile](Makefile) to build, test, or do pre-commit checks. 59 | 60 | ### Testing 61 | 62 | #### Environment Variables 63 | 64 | The testing framework is set up to use environment variables that are found in a file called ```test.env``` in the **root directory** of the project. This file has been added to the ```.gitignore``` file in this project so it will be ignored if it exists in your file structure when committing the code. If you are running tests, this file should exist and have the following values configured: 65 | 66 | ``` bash 67 | BOMBER_PROVIDER_USERNAME={{your OSS Index user name}} 68 | BOMBER_PROVIDER_TOKEN={{your OSS Index API Token}} 69 | ``` 70 | To load this file, you use the following command in your terminal before opening an editor such as Visual Studio Code (from your terminal). 71 | 72 | ``` bash 73 | export $(cat *.env) 74 | ``` 75 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # HELP 2 | # This will output the help for each task 3 | # thanks to https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html 4 | .PHONY: help 5 | 6 | help: ## This help 7 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) 8 | 9 | .DEFAULT_GOAL := help 10 | 11 | title: 12 | @echo "bomber Makefile" 13 | @echo "---------------" 14 | 15 | build: ## Builds the application 16 | go get -u ./... 17 | go mod tidy 18 | go build 19 | 20 | test: ## Runs tests and coverage 21 | go test -v -coverprofile=coverage.out ./... 22 | go tool cover -func=coverage.out 23 | go tool cover -html=coverage.out -o coverage.html 24 | gcov2lcov -infile=coverage.out -outfile=coverage.lcov 25 | 26 | check: build ## Tests the pre-commit hooks if they exist 27 | 28 | all: title build test ## Makes all targets -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | We scan for vulnerabilities in both our code and our third party dependencies on a continuous basis. If you discover a vulnerability please create an issue in this repository and one of the administrators will triage. 6 | -------------------------------------------------------------------------------- /_TESTDATA_/ignore/bomber.ignore: -------------------------------------------------------------------------------- 1 | CVE-2022-31163 2 | CVE-2022-23520 -------------------------------------------------------------------------------- /_TESTDATA_/sbom/cargo-valid-bom-1.3.json: -------------------------------------------------------------------------------- 1 | { 2 | "bomFormat": "CycloneDX", 3 | "specVersion": "1.3", 4 | "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", 5 | "version": 1, 6 | "metadata": { 7 | "timestamp": "2020-04-13T20:20:39+00:00", 8 | "tools": [ 9 | { 10 | "vendor": "Awesome Vendor", 11 | "name": "Awesome Tool", 12 | "version": "9.1.2", 13 | "hashes": [ 14 | { 15 | "alg": "SHA-1", 16 | "content": "25ed8e31b995bb927966616df2a42b979a2717f0" 17 | }, 18 | { 19 | "alg": "SHA-256", 20 | "content": "a74f733635a19aefb1f73e5947cef59cd7440c6952ef0f03d09d974274cbd6df" 21 | } 22 | ] 23 | } 24 | ], 25 | "authors": [ 26 | { 27 | "name": "Samantha Wright", 28 | "email": "samantha.wright@example.com", 29 | "phone": "800-555-1212" 30 | } 31 | ], 32 | "component": { 33 | "type": "application", 34 | "author": "Acme Super Heros", 35 | "name": "Acme Application", 36 | "version": "9.1.1", 37 | "swid": { 38 | "tagId": "swidgen-242eb18a-503e-ca37-393b-cf156ef09691_9.1.1", 39 | "name": "Acme Application", 40 | "version": "9.1.1", 41 | "text": { 42 | "contentType": "text/xml", 43 | "encoding": "base64", 44 | "content": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiID8+CjxTb2Z0d2FyZUlkZW50aXR5IHhtbDpsYW5nPSJFTiIgbmFtZT0iQWNtZSBBcHBsaWNhdGlvbiIgdmVyc2lvbj0iOS4xLjEiIAogdmVyc2lvblNjaGVtZT0ibXVsdGlwYXJ0bnVtZXJpYyIgCiB0YWdJZD0ic3dpZGdlbi1iNTk1MWFjOS00MmMwLWYzODItM2YxZS1iYzdhMmE0NDk3Y2JfOS4xLjEiIAogeG1sbnM9Imh0dHA6Ly9zdGFuZGFyZHMuaXNvLm9yZy9pc28vMTk3NzAvLTIvMjAxNS9zY2hlbWEueHNkIj4gCiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiAKIHhzaTpzY2hlbWFMb2NhdGlvbj0iaHR0cDovL3N0YW5kYXJkcy5pc28ub3JnL2lzby8xOTc3MC8tMi8yMDE1LWN1cnJlbnQvc2NoZW1hLnhzZCBzY2hlbWEueHNkIiA+CiAgPE1ldGEgZ2VuZXJhdG9yPSJTV0lEIFRhZyBPbmxpbmUgR2VuZXJhdG9yIHYwLjEiIC8+IAogIDxFbnRpdHkgbmFtZT0iQWNtZSwgSW5jLiIgcmVnaWQ9ImV4YW1wbGUuY29tIiByb2xlPSJ0YWdDcmVhdG9yIiAvPiAKPC9Tb2Z0d2FyZUlkZW50aXR5Pg==" 45 | } 46 | } 47 | }, 48 | "manufacture": { 49 | "name": "Acme, Inc.", 50 | "url": [ 51 | "https://example.com" 52 | ], 53 | "contact": [ 54 | { 55 | "name": "Acme Professional Services", 56 | "email": "professional.services@example.com" 57 | } 58 | ] 59 | }, 60 | "supplier": { 61 | "name": "Acme, Inc.", 62 | "url": [ 63 | "https://example.com" 64 | ], 65 | "contact": [ 66 | { 67 | "name": "Acme Distribution", 68 | "email": "distribution@example.com" 69 | } 70 | ] 71 | } 72 | }, 73 | "components": [ 74 | { 75 | "bom-ref": "pkg:npm/acme/component@1.0.0", 76 | "type": "library", 77 | "publisher": "Acme Inc", 78 | "group": "com.acme", 79 | "name": "tomcat-catalina", 80 | "version": "9.0.14", 81 | "hashes": [ 82 | { 83 | "alg": "MD5", 84 | "content": "3942447fac867ae5cdb3229b658f4d48" 85 | }, 86 | { 87 | "alg": "SHA-1", 88 | "content": "e6b1000b94e835ffd37f4c6dcbdad43f4b48a02a" 89 | }, 90 | { 91 | "alg": "SHA-256", 92 | "content": "f498a8ff2dd007e29c2074f5e4b01a9a01775c3ff3aeaf6906ea503bc5791b7b" 93 | }, 94 | { 95 | "alg": "SHA-512", 96 | "content": "e8f33e424f3f4ed6db76a482fde1a5298970e442c531729119e37991884bdffab4f9426b7ee11fccd074eeda0634d71697d6f88a460dce0ac8d627a29f7d1282" 97 | } 98 | ], 99 | "licenses": [ 100 | { 101 | "license": { 102 | "id": "Apache-2.0", 103 | "text": { 104 | "contentType": "text/plain", 105 | "encoding": null, 106 | "content": "License text here" 107 | }, 108 | "url": "https://www.apache.org/licenses/LICENSE-2.0.txt" 109 | } 110 | } 111 | ], 112 | "purl": "pkg:npm/acme/component@1.0.0", 113 | "pedigree": { 114 | "ancestors": [ 115 | { 116 | "type": "library", 117 | "publisher": "Acme Inc", 118 | "group": "com.acme", 119 | "name": "tomcat-catalina", 120 | "version": "9.0.14" 121 | }, 122 | { 123 | "type": "library", 124 | "publisher": "Acme Inc", 125 | "group": "com.acme", 126 | "name": "tomcat-catalina", 127 | "version": "9.0.14" 128 | } 129 | ], 130 | "commits": [ 131 | { 132 | "uid": "123", 133 | "url": "https://example.com", 134 | "author": { 135 | "timestamp": "2018-11-13T20:20:39+00:00", 136 | "name": "", 137 | "email": "" 138 | } 139 | } 140 | ] 141 | } 142 | }, 143 | { 144 | "type": "library", 145 | "supplier": { 146 | "name": "Example, Inc.", 147 | "url": [ 148 | "https://example.com", 149 | "https://example.net" 150 | ], 151 | "contact": [ 152 | { 153 | "name": "Example Support AMER Distribution", 154 | "email": "support@example.com", 155 | "phone": "800-555-1212" 156 | }, 157 | { 158 | "name": "Example Support APAC", 159 | "email": "support@apac.example.com" 160 | } 161 | ] 162 | }, 163 | "author": "Example Super Heros", 164 | "group": "org.example", 165 | "name": "mylibrary", 166 | "version": "1.0.0" 167 | } 168 | ], 169 | "dependencies": [ 170 | { 171 | "ref": "pkg:npm/acme/component@1.0.0", 172 | "dependsOn": [ 173 | "pkg:npm/acme/component@1.0.0" 174 | ] 175 | } 176 | ] 177 | } 178 | -------------------------------------------------------------------------------- /_TESTDATA_/sbom/expression-license.json: -------------------------------------------------------------------------------- 1 | { 2 | "bomFormat": "CycloneDX", 3 | "specVersion": "1.4", 4 | "version": 1, 5 | "serialNumber": "ABC123", 6 | "metadata": { 7 | }, 8 | "components": [ 9 | { 10 | "type": "library", 11 | "name": "foo", 12 | "version": "0.4.0", 13 | "bom-ref": "foo-bar@0.4.0", 14 | "purl": "pkg:npm/juice-shop@11.1.2", 15 | "licenses": [ 16 | { 17 | "expression": "(AFL-2.1 OR BSD-3-Clause)" 18 | } 19 | ], 20 | "properties": [ 21 | { 22 | "name": "cdx:npm:package:path", 23 | "value": "node_modules/foo-bar" 24 | } 25 | ] 26 | } 27 | ], 28 | "dependencies": [] 29 | } -------------------------------------------------------------------------------- /_TESTDATA_/sbom/marketplace.json: -------------------------------------------------------------------------------- 1 | { 2 | "SPDXID": "SPDXRef-DOCUMENT", 3 | "spdxVersion": "SPDX-2.3", 4 | "creationInfo": { 5 | "created": "2024-06-27T10:03:46Z", 6 | "creators": ["Tool: GitHub.com-Dependency-Graph"] 7 | }, 8 | "name": "com.github.AppDirect/marketplace-blimp", 9 | "dataLicense": "CC0-1.0", 10 | "documentDescribes": ["SPDXRef-com.github.AppDirect-marketplace-blimp"], 11 | "documentNamespace": "https://github.com/AppDirect/marketplace-blimp/dependency_graph/sbom-047b3c3f9ffcf7a4", 12 | "packages": [ 13 | { 14 | "SPDXID": "SPDXRef-com.github.AppDirect-marketplace-blimp", 15 | "name": "com.github.AppDirect/marketplace-blimp", 16 | "versionInfo": "", 17 | "downloadLocation": "git+https://github.com/AppDirect/marketplace-blimp", 18 | "filesAnalyzed": false, 19 | "supplier": "NOASSERTION", 20 | "externalRefs": [ 21 | { 22 | "referenceCategory": "PACKAGE-MANAGER", 23 | "referenceType": "purl", 24 | "referenceLocator": "pkg:github/AppDirect/marketplace-blimp" 25 | } 26 | ] 27 | }, 28 | { 29 | "SPDXID": "SPDXRef-actions-AppDirect-actions-.github-workflows-dependency-review.yml-master", 30 | "name": "actions:AppDirect/actions/.github/workflows/dependency-review.yml", 31 | "versionInfo": "master", 32 | "downloadLocation": "NOASSERTION", 33 | "filesAnalyzed": false, 34 | "supplier": "NOASSERTION", 35 | "externalRefs": [ 36 | { 37 | "referenceCategory": "PACKAGE-MANAGER", 38 | "referenceLocator": "pkg:githubactions/AppDirect/actions/.github/workflows/dependency-review.yml@master", 39 | "referenceType": "purl" 40 | } 41 | ] 42 | } 43 | ], 44 | "relationships": [ 45 | { 46 | "relationshipType": "DEPENDS_ON", 47 | "spdxElementId": "SPDXRef-com.github.AppDirect-marketplace-blimp", 48 | "relatedSpdxElement": "SPDXRef-actions-AppDirect-actions-.github-workflows-dependency-review.yml-master" 49 | } 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /_TESTDATA_/sbom/merged/sbom_specver1.5.json: -------------------------------------------------------------------------------- 1 | { 2 | "bomFormat": "CycloneDX", 3 | "specVersion": "1.5", 4 | "serialNumber": "urn:uuid:cd2f853a-98cc-475d-b533-6ce32fa3997b", 5 | "version": 1, 6 | "metadata": { 7 | "tools": { 8 | "components": [ 9 | { 10 | "type": "application", 11 | "bom-ref": "pkg:npm/@cyclonedx/cdxgen@10.9.11", 12 | "author": "OWASP Foundation", 13 | "publisher": "OWASP Foundation", 14 | "group": "@cyclonedx", 15 | "name": "cdxgen", 16 | "version": "10.9.11", 17 | "purl": "pkg:npm/%40cyclonedx/cdxgen@10.9.11" 18 | } 19 | ] 20 | }, 21 | "component": { 22 | "type": "application", 23 | "name": "service-name" 24 | } 25 | }, 26 | "components": [ 27 | { 28 | "type": "library", 29 | "name": "openjpeg", 30 | "version": "2.5.2", 31 | "description": "OpenJPEG is an open-source JPEG 2000 codec written in C language.", 32 | "purl": "pkg:conan/openjpeg@2.5.2", 33 | "externalReferences": [ 34 | { 35 | "url": "https://github.com/uclouvain/openjpeg", 36 | "type": "distribution" 37 | } 38 | ], 39 | "properties": [ 40 | { 41 | "name": "language", 42 | "value": "C\u002B\u002B" 43 | } 44 | ] 45 | }, 46 | { 47 | "type": "library", 48 | "name": "openssl", 49 | "version": "3.3.1", 50 | "description": "A toolkit for the Transport Layer Security (TLS) and Secure Sockets Layer (SSL) protocols", 51 | "purl": "pkg:conan/openssl@3.3.1", 52 | "externalReferences": [ 53 | { 54 | "url": "https://github.com/openssl/openssl", 55 | "type": "distribution" 56 | } 57 | ], 58 | "properties": [ 59 | { 60 | "name": "language", 61 | "value": "C\u002B\u002B" 62 | } 63 | ] 64 | }, 65 | { 66 | "type": "library", 67 | "name": "protobuf", 68 | "version": "3.21.12", 69 | "description": "Protocol Buffers - Google\u0027s data interchange format", 70 | "purl": "pkg:conan/protobuf@3.21.12", 71 | "externalReferences": [ 72 | { 73 | "url": "https://github.com/protocolbuffers/protobuf", 74 | "type": "distribution" 75 | } 76 | ], 77 | "properties": [ 78 | { 79 | "name": "language", 80 | "value": "C\u002B\u002B" 81 | } 82 | ] 83 | } 84 | ] 85 | } 86 | -------------------------------------------------------------------------------- /_TESTDATA_/sbom/merged/sbom_specver1.6.json: -------------------------------------------------------------------------------- 1 | { 2 | "bomFormat": "CycloneDX", 3 | "specVersion": "1.6", 4 | "serialNumber": "urn:uuid:cd2f853a-98cc-475d-b533-6ce32fa3997b", 5 | "version": 1, 6 | "metadata": { 7 | "tools": { 8 | "components": [ 9 | { 10 | "type": "application", 11 | "bom-ref": "pkg:npm/@cyclonedx/cdxgen@10.9.11", 12 | "author": "OWASP Foundation", 13 | "publisher": "OWASP Foundation", 14 | "group": "@cyclonedx", 15 | "name": "cdxgen", 16 | "version": "10.9.11", 17 | "purl": "pkg:npm/%40cyclonedx/cdxgen@10.9.11" 18 | } 19 | ] 20 | }, 21 | "component": { 22 | "type": "application", 23 | "name": "service-name" 24 | } 25 | }, 26 | "components": [ 27 | { 28 | "type": "library", 29 | "name": "openjpeg", 30 | "version": "2.5.2", 31 | "description": "OpenJPEG is an open-source JPEG 2000 codec written in C language.", 32 | "purl": "pkg:conan/openjpeg@2.5.2", 33 | "externalReferences": [ 34 | { 35 | "url": "https://github.com/uclouvain/openjpeg", 36 | "type": "distribution" 37 | } 38 | ], 39 | "properties": [ 40 | { 41 | "name": "language", 42 | "value": "C\u002B\u002B" 43 | } 44 | ] 45 | }, 46 | { 47 | "type": "library", 48 | "name": "openssl", 49 | "version": "3.3.1", 50 | "description": "A toolkit for the Transport Layer Security (TLS) and Secure Sockets Layer (SSL) protocols", 51 | "purl": "pkg:conan/openssl@3.3.1", 52 | "externalReferences": [ 53 | { 54 | "url": "https://github.com/openssl/openssl", 55 | "type": "distribution" 56 | } 57 | ], 58 | "properties": [ 59 | { 60 | "name": "language", 61 | "value": "C\u002B\u002B" 62 | } 63 | ] 64 | }, 65 | { 66 | "type": "library", 67 | "name": "protobuf", 68 | "version": "3.21.12", 69 | "description": "Protocol Buffers - Google\u0027s data interchange format", 70 | "purl": "pkg:conan/protobuf@3.21.12", 71 | "externalReferences": [ 72 | { 73 | "url": "https://github.com/protocolbuffers/protobuf", 74 | "type": "distribution" 75 | } 76 | ], 77 | "properties": [ 78 | { 79 | "name": "language", 80 | "value": "C\u002B\u002B" 81 | } 82 | ] 83 | } 84 | ] 85 | } 86 | -------------------------------------------------------------------------------- /_TESTDATA_/sbom/small.cyclonedx.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json", 3 | "bomFormat": "CycloneDX", 4 | "specVersion": "1.5", 5 | "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", 6 | "version": 1, 7 | "components": [ 8 | { 9 | "purl": "pkg:pypi/pycrypto@2.6.1" 10 | }, 11 | { 12 | "purl": "pkg:pypi/pycryptopayapi@0.0.8" 13 | }, 14 | { 15 | "purl": "pkg:npm/body-parser@1.19.0" 16 | }, 17 | { 18 | "purl": "pkg:npm/qs@6.7.0" 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | // Package cmd contains all of the commands that may be executed in the cli 2 | package cmd 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "io" 8 | "log" 9 | "os" 10 | 11 | "github.com/google/go-github/github" 12 | "github.com/gookit/color" 13 | "github.com/spf13/afero" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | var ( 18 | version = "0.5.1" 19 | output string 20 | //Afs stores a global OS Filesystem that is used throughout bomber 21 | Afs = &afero.Afero{Fs: afero.NewOsFs()} 22 | //Verbose determines if the execution of hing should output verbose information 23 | debug bool 24 | rootCmd = &cobra.Command{ 25 | Use: "bomber [flags] file", 26 | Example: " bomber scan --output html test.cyclonedx.json", 27 | Short: "Scans SBOMs for security vulnerabilities.", 28 | Version: version, 29 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 30 | if !debug { 31 | log.SetOutput(io.Discard) 32 | } 33 | if output != "json" { 34 | log.Println("Start") 35 | fmt.Println() 36 | printAsciiArt() 37 | fmt.Println() 38 | fmt.Println("DKFM - DevOps Kung Fu Mafia") 39 | fmt.Println("https://github.com/devops-kung-fu/bomber") 40 | fmt.Printf("Version: %s\n", version) 41 | fmt.Println() 42 | checkForNewVersion(version) 43 | } 44 | }, 45 | } 46 | ) 47 | 48 | func printAsciiArt() { 49 | response := ` 50 | __ __ 51 | / / ___ __ _ / / ___ ____ 52 | / _ \/ _ \/ ' \/ _ \/ -_) __/ 53 | /_.__/\___/_/_/_/_.__/\__/_/ ` 54 | color.Style{color.FgWhite, color.OpBold}.Println(response) 55 | } 56 | 57 | // Execute creates the command tree and handles any error condition returned 58 | func Execute() { 59 | if err := rootCmd.Execute(); err != nil { 60 | fmt.Println(err) 61 | os.Exit(1) 62 | } 63 | } 64 | 65 | func init() { 66 | rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "displays debug level log messages.") 67 | rootCmd.PersistentFlags().StringVar(&output, "output", "stdout", "how bomber should output findings (json, json-file, html, ai, md, stdout)") 68 | } 69 | 70 | func checkForNewVersion(currentVersion string) { 71 | ctx := context.Background() 72 | client := github.NewClient(nil) 73 | 74 | release, _, err := client.Repositories.GetLatestRelease(ctx, "devops-kung-fu", "bomber") 75 | if err != nil { 76 | log.Printf("Error fetching latest release: %v\n", err) 77 | return 78 | } 79 | 80 | latestVersion := release.GetTagName()[1:] // Remove leading 'v' 81 | if latestVersion != currentVersion { 82 | color.Yellow.Printf("A newer version of bomber is available (%s)\n\n", latestVersion) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /cmd/scan.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "slices" 7 | "strings" 8 | 9 | "github.com/devops-kung-fu/common/util" 10 | "github.com/gookit/color" 11 | "github.com/spf13/cobra" 12 | 13 | "github.com/devops-kung-fu/bomber/lib" 14 | "github.com/devops-kung-fu/bomber/providers" 15 | "github.com/devops-kung-fu/bomber/renderers" 16 | ) 17 | 18 | var ( 19 | scanner lib.Scanner 20 | 21 | // summary, detailed bool 22 | scanCmd = &cobra.Command{ 23 | Use: "scan", 24 | Short: "Scans a provided SBOM file or folder containing SBOMs for vulnerabilities.", 25 | PreRun: func(cmd *cobra.Command, args []string) { 26 | if slices.Contains(strings.Split(output, ","), "ai") && !slices.Contains(scanner.Enrichment, "openai") { 27 | scanner.Enrichment = append(scanner.Enrichment, "openai") 28 | } 29 | r, err := renderers.NewRenderer(output) 30 | if err != nil { 31 | color.Red.Printf("%v\n\n", err) 32 | _ = cmd.Help() 33 | os.Exit(1) 34 | } 35 | scanner.Renderers = r 36 | p, err := providers.NewProvider(scanner.ProviderName) 37 | if err != nil { 38 | color.Red.Printf("%v\n\n", err) 39 | _ = cmd.Help() 40 | os.Exit(1) 41 | } 42 | scanner.Provider = p 43 | }, 44 | Run: func(cmd *cobra.Command, args []string) { 45 | scanner.Version = version 46 | scanner.Output = output 47 | scanner.Afs = Afs 48 | code, err := scanner.Scan(args) 49 | if err != nil { 50 | util.PrintErr(err) 51 | os.Exit(1) 52 | } 53 | 54 | log.Println("Finished") 55 | os.Exit(code) 56 | }, 57 | } 58 | ) 59 | 60 | func init() { 61 | rootCmd.AddCommand(scanCmd) 62 | scanCmd.PersistentFlags().StringVar(&scanner.Credentials.Username, "username", "", "the user name for the provider being used.") 63 | scanCmd.PersistentFlags().StringVar(&scanner.Credentials.ProviderToken, "token", "", "the API token for the provider being used.") 64 | scanCmd.PersistentFlags().StringVar(&scanner.Credentials.OpenAIAPIKey, "openai-api-key", "", "an OpenAI API key used for generating AI output. AI Reports are EXPERIMENTAL.") 65 | scanCmd.PersistentFlags().StringVar(&scanner.ProviderName, "provider", "osv", "the vulnerability provider (ossindex, osv, snyk, github).") 66 | scanCmd.PersistentFlags().StringVar(&scanner.IgnoreFile, "ignore-file", "", "an optional file containing CVEs to ignore when rendering output.") 67 | scanCmd.PersistentFlags().StringVar(&scanner.Severity, "severity", "", "anything equal to or above this severity will be returned with non-zero error code.") 68 | scanCmd.PersistentFlags().BoolVar(&scanner.ExitCode, "exitcode", false, "if set will return an exit code representing the highest severity detected.") 69 | scanCmd.Flags().StringSliceVar(&scanner.Enrichment, "enrich", nil, "Enrich data with additional fields (epss, openai (EXTREMELY EXPERIMENTATL)") 70 | } 71 | -------------------------------------------------------------------------------- /doc/providers/github.md: -------------------------------------------------------------------------------- 1 | ![](../../img/providers/github.png) 2 | 3 | # GitHub Advisory Database 4 | 5 | In order to use `bomber` with the [Github Advisory Database](https://github.com/advisories) you need to have a GitHub account. 6 | 7 | Once you log in, you'll want to navigate to your [settings](https://github.com/settings/tokens) and and create a Personal Access Token (PAT). **Please don't share your token with anyone.** 8 | 9 | Once you have your token, you can either set an environment variable called `GITHUB_TOKEN` or utilize the token on the command line as such: 10 | 11 | ```bash 12 | # Using a provider that requires credentials (ossindex) 13 | bomber scan --provider=github --token=xxx sbom.json 14 | ``` 15 | 16 | ## Supported ecosystems 17 | 18 | At this time, the [Github Advisory Database](https://github.com/advisories) supports the following ecosystems: 19 | 20 | - GitHub Actions 21 | - Composer 22 | - Erlang 23 | - Go 24 | - Maven 25 | - npm 26 | - NuGet 27 | - Pip 28 | - PyPI 29 | - RubyGems 30 | - Rust 31 | -------------------------------------------------------------------------------- /doc/providers/ossindex.md: -------------------------------------------------------------------------------- 1 | ![](../../img/providers/sonatype.png) 2 | 3 | # OSS Index 4 | 5 | In order to use ```bomber``` with the [Sonatype OSS Index](https://ossindex.sonatype.org) you need to get an account. Head over to the site, and create a free account, and make note of your ```username``` (this will be the email that you registered with). 6 | 7 | Once you log in, you'll want to navigate to your [settings](https://ossindex.sonatype.org/user/settings) and make note of your API ```token```. **Please don't share your token with anyone.** 8 | 9 | Once you have your token, 10 | 11 | ``` bash 12 | # Using a provider that requires credentials (ossindex) 13 | bomber scan --provider=ossindex --username=xxx --token=xxx sbom.json 14 | ``` 15 | 16 | ## Supported ecosystems 17 | 18 | At this time, the [Sonatype OSS Index](https://ossindex.sonatype.org) supports the following ecosystems: 19 | 20 | - Maven 21 | - NPM 22 | - Go 23 | - PyPi 24 | - Nuget 25 | - RubyGems 26 | - Cargo 27 | - CocoaPods 28 | - Composer 29 | - Conan 30 | - Conda 31 | - CRAN 32 | - RPM 33 | - Swift -------------------------------------------------------------------------------- /doc/providers/osv.md: -------------------------------------------------------------------------------- 1 | ![](../../img/providers/osv.png) 2 | 3 | [OSV](https://osv.dev) is the default provider for `bomber`. It is an open, precise, and distributed approach to producing and consuming vulnerability information for open source. 4 | 5 | **You don't need to register for any service, get a password, or a token.** Just use `bomber` without a provider flag and away you go like this: 6 | 7 | ```bash 8 | bomber scan test.cyclonedx.json 9 | ``` 10 | 11 | ## Supported ecosystems 12 | 13 | At this time, the [OSV](https://osv.dev) supports the following ecosystems: 14 | 15 | - AlmaLinux 16 | - Alpine 17 | - Android 18 | - Bitnami 19 | - crates.io 20 | - Curl 21 | - Debian GNU/Linux 22 | - Git (including C/C++) 23 | - GitHub Actions 24 | - Go 25 | - Haskell 26 | - Hex 27 | - Linux kernel 28 | - Maven 29 | - npm 30 | - NuGet 31 | - OSS-Fuzz 32 | - Packagist 33 | - Pub 34 | - PyPI 35 | - Python 36 | - R (CRAN and Bioconductor) 37 | - Rocky Linux 38 | - RubyGems 39 | - SwiftURL 40 | - Ubuntu OS 41 | 42 | ## OSV Notes 43 | 44 | Additionally, there are cases where OSV does not return a Severity, or a CVE/CWE. In these rare cases, `bomber` will output "UNSPECIFIED", and "UNDEFINED" respectively. 45 | -------------------------------------------------------------------------------- /doc/providers/snyk.md: -------------------------------------------------------------------------------- 1 | ![](../../img/providers/snyk.png) 2 | 3 | # Snyk 4 | 5 | In order to use `bomber` with Snyk you will need to be a Snyk customer. Access requires your Snyk API Token, which you can retrieve from the web interface or by running: 6 | 7 | ``` 8 | snyk config get api 9 | ``` 10 | 11 | Once you have your token you can run bomber like so: 12 | 13 | ``` 14 | bomber scan --provider snyk --token xxx sbom.json 15 | ``` 16 | 17 | Note rather than passing the API token explicitly, you can also set this as an environment variable, either as `SNYK_TOKEN` or the generic `BOMBER_PROVIDER_TOKEN`. 18 | 19 | By default, `bomber` will use Snyk's global API (https://api.snyk.io). To use a different Snyk API, you can specify its base URL on the `SNYK_API` environment variable. 20 | 21 | ``` 22 | SNYK_API=https://api.eu.snyk.io bomber scan --provider snyk sbom.json 23 | ``` 24 | 25 | 26 | ## Supported ecosystems 27 | 28 | At this time, the Snyk provider supports the following ecosystems: 29 | 30 | * npm 31 | * Maven 32 | * CocoaPods 33 | * Composer 34 | * RubyGems 35 | * Nuget 36 | * PyPi 37 | * Hex 38 | * Cargo 39 | * Swift 40 | * C/C++ 41 | * apk 42 | * Debian 43 | * Docker 44 | * RPM 45 | -------------------------------------------------------------------------------- /enrichers/enrichmentfactory.go: -------------------------------------------------------------------------------- 1 | // package enrichers are meant to enrich vulnerability data from other sources 2 | package enrichers 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/devops-kung-fu/bomber/enrichers/epss" 8 | "github.com/devops-kung-fu/bomber/enrichers/openai" 9 | "github.com/devops-kung-fu/bomber/models" 10 | ) 11 | 12 | // NewProvider will return a provider interface for the requested vulnerability services 13 | func NewEnricher(name string) (enricher models.Enricher, err error) { 14 | switch name { 15 | case "epss": 16 | enricher = epss.Enricher{} 17 | case "openai": 18 | enricher = openai.Enricher{} 19 | default: 20 | 21 | err = fmt.Errorf("%s is not a valid provider type", name) 22 | } 23 | return 24 | } 25 | -------------------------------------------------------------------------------- /enrichers/enrichmentfactory_test.go: -------------------------------------------------------------------------------- 1 | package enrichers 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/devops-kung-fu/bomber/enrichers/epss" 9 | ) 10 | 11 | func TestNewEnricher(t *testing.T) { 12 | enricher, err := NewEnricher("epss") 13 | assert.NoError(t, err) 14 | assert.IsType(t, epss.Enricher{}, enricher) 15 | _, err = NewEnricher("test") 16 | assert.Error(t, err) 17 | } 18 | -------------------------------------------------------------------------------- /enrichers/epss/epss.go: -------------------------------------------------------------------------------- 1 | // Package epss provides functionality to enrich vulnerabilities with epss data. 2 | package epss 3 | 4 | import ( 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "strings" 10 | "time" 11 | 12 | "github.com/go-resty/resty/v2" 13 | 14 | "github.com/devops-kung-fu/bomber/models" 15 | ) 16 | 17 | const ( 18 | epssBaseURL = "https://api.first.org/data/v1/epss?cve=" 19 | pageSize = 150 20 | ) 21 | 22 | // Provider represents an EPSS enricher 23 | type Enricher struct{} 24 | 25 | var client *resty.Client 26 | 27 | func init() { 28 | // Cloning the transport ensures a proper working http client that respects the proxy settings 29 | transport := http.DefaultTransport.(*http.Transport).Clone() 30 | transport.TLSHandshakeTimeout = 60 * time.Second 31 | client = resty.New().SetTransport(transport) 32 | } 33 | 34 | // TODO: this needs to be refactored so we can batch the scanning and de-duplicate. Each component has it's own list of []models.Vulnerability and this function is called multiple times. At least the implementation here reduces the calls by batching per component. 35 | 36 | // Enrich adds epss score data to vulnerabilities. 37 | func (Enricher) Enrich(vulnerabilities []models.Vulnerability, credentials *models.Credentials) ([]models.Vulnerability, error) { 38 | var enrichedVulnerabilities []models.Vulnerability 39 | 40 | for i := 0; i < len(vulnerabilities); i += pageSize { 41 | endIndex := i + pageSize 42 | 43 | if endIndex > len(vulnerabilities) { 44 | endIndex = len(vulnerabilities) 45 | } 46 | 47 | cvesBatch := getCveBatch(vulnerabilities[i:endIndex]) 48 | 49 | epss, err := fetchEpssData(cvesBatch) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | log.Printf("%v EPSS responses for %v vulnerabilities", epss.Total, len(vulnerabilities)) 55 | 56 | for i, v := range vulnerabilities { 57 | for _, sv := range epss.Scores { 58 | if sv.Cve == v.Cve { 59 | vulnerabilities[i].Epss = sv 60 | } 61 | } 62 | } 63 | 64 | enrichedVulnerabilities = append(enrichedVulnerabilities, vulnerabilities...) 65 | } 66 | 67 | return enrichedVulnerabilities, nil 68 | } 69 | 70 | // getCveBatch extracts CVE identifiers from a slice of Vulnerability models 71 | // and returns a new slice containing only the CVE identifiers. 72 | func getCveBatch(vulnerabilities []models.Vulnerability) []string { 73 | identifiers := make([]string, len(vulnerabilities)) 74 | for i, v := range vulnerabilities { 75 | identifiers[i] = v.Cve 76 | } 77 | return identifiers 78 | } 79 | 80 | // fetchEpssData retrieves EPSS (Exploit Prediction Scoring System) data for 81 | // a batch of CVEs from the EPSS API. It sends a GET request to the API with 82 | // the specified CVEs, parses the JSON response, and returns an Epss model 83 | // containing the fetched data. If the request or parsing fails, an error is returned. 84 | func fetchEpssData(cves []string) (models.Epss, error) { 85 | 86 | // Create the URL by joining the base URL and CVEs. 87 | url := fmt.Sprintf("%s%s", epssBaseURL, strings.Join(cves, ",")) 88 | 89 | resp, _ := client.R(). 90 | Get(url) 91 | 92 | log.Println("EPSS Response Status:", resp.StatusCode()) 93 | 94 | if resp.StatusCode() == http.StatusOK { 95 | var epss models.Epss 96 | if err := json.Unmarshal(resp.Body(), &epss); err != nil { 97 | return models.Epss{}, err 98 | } 99 | return epss, nil 100 | } 101 | 102 | return models.Epss{}, fmt.Errorf("EPSS API request failed with status code: %d", resp.StatusCode()) 103 | } 104 | -------------------------------------------------------------------------------- /enrichers/epss/epss_test.go: -------------------------------------------------------------------------------- 1 | package epss 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/jarcoal/httpmock" 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/devops-kung-fu/bomber/models" 10 | ) 11 | 12 | func TestEnrich(t *testing.T) { 13 | enricher := Enricher{} 14 | vulnerabilities := []models.Vulnerability{ 15 | { 16 | Cve: "CVE-2021-43138", 17 | }, 18 | { 19 | Cve: "CVE-2020-15084", 20 | }, 21 | { 22 | Cve: "CVE-2020-28282", 23 | }, 24 | { 25 | Cve: "sonatype-2020-1214", 26 | }, 27 | } 28 | 29 | httpmock.Activate() 30 | defer httpmock.DeactivateAndReset() 31 | 32 | httpmock.RegisterResponder("GET", "https://api.first.org/data/v1/epss", 33 | httpmock.NewBytesResponder(200, epssTestResponse())) 34 | 35 | enriched, err := enricher.Enrich(vulnerabilities, nil) 36 | 37 | assert.NoError(t, err) 38 | assert.Len(t, enriched, 4) 39 | 40 | assert.Empty(t, enriched[3].Epss.Cve) 41 | assert.Equal(t, enriched[0].Epss.Cve, "CVE-2021-43138") 42 | 43 | } 44 | 45 | func TestEnrich_Error(t *testing.T) { 46 | 47 | httpmock.Activate() 48 | defer httpmock.DeactivateAndReset() 49 | 50 | httpmock.RegisterResponder("GET", "https://api.first.org/data/v1/epss", 51 | httpmock.NewBytesResponder(404, []byte{})) 52 | 53 | cves := []string{"CVE-2023-22795", "CVE-2023-22792", "CVE-2022-23633", "CVE-2022-22577"} 54 | _, err := fetchEpssData(cves) 55 | assert.NoError(t, err) 56 | } 57 | 58 | func epssTestResponse() []byte { 59 | response := ` 60 | { 61 | "status": "OK", 62 | "status-code": 200, 63 | "version": "1.0", 64 | "access-control-allow-headers": "x-requested-with", 65 | "access": "public", 66 | "total": 13, 67 | "offset": 0, 68 | "limit": 100, 69 | "data": [ 70 | { 71 | "cve": "CVE-2023-22795", 72 | "epss": "0.027190000", 73 | "percentile": "0.906830000", 74 | "date": "2024-08-15" 75 | }, 76 | { 77 | "cve": "CVE-2023-22792", 78 | "epss": "0.001150000", 79 | "percentile": "0.458150000", 80 | "date": "2024-08-15" 81 | }, 82 | { 83 | "cve": "CVE-2022-23633", 84 | "epss": "0.002130000", 85 | "percentile": "0.597890000", 86 | "date": "2024-08-15" 87 | }, 88 | { 89 | "cve": "CVE-2022-22577", 90 | "epss": "0.005230000", 91 | "percentile": "0.772150000", 92 | "date": "2024-08-15" 93 | } 94 | ] 95 | }` 96 | return []byte(response) 97 | } 98 | -------------------------------------------------------------------------------- /enrichers/openai/openai.go: -------------------------------------------------------------------------------- 1 | // package openai enriches vulnerability information 2 | package openai 3 | 4 | import ( 5 | "bytes" 6 | "context" 7 | "errors" 8 | "fmt" 9 | "log" 10 | "os" 11 | "text/template" 12 | 13 | openai "github.com/sashabaranov/go-openai" 14 | 15 | "github.com/devops-kung-fu/bomber/models" 16 | ) 17 | 18 | // Provider represents the openai enricher 19 | type Enricher struct{} 20 | 21 | // Enrich adds additional information to vulnerabilities 22 | func (Enricher) Enrich(vulnerabilities []models.Vulnerability, credentials *models.Credentials) ([]models.Vulnerability, error) { 23 | if err := validateCredentials(credentials); err != nil { 24 | return nil, fmt.Errorf("could not validate openai credentials: %w", err) 25 | } 26 | var enrichedVulnerabilities []models.Vulnerability 27 | for _, v := range vulnerabilities { 28 | enriched := fetch(v, credentials) 29 | enrichedVulnerabilities = append(enrichedVulnerabilities, enriched) 30 | } 31 | return enrichedVulnerabilities, nil 32 | } 33 | 34 | func validateCredentials(credentials *models.Credentials) (err error) { 35 | if credentials == nil { 36 | return errors.New("credentials cannot be nil") 37 | } 38 | 39 | if credentials.OpenAIAPIKey == "" { 40 | credentials.OpenAIAPIKey = os.Getenv("OPENAI_API_KEY") 41 | } 42 | 43 | if credentials.OpenAIAPIKey == "" { 44 | err = errors.New("bomber requires an openai key to enrich vulnerability data") 45 | } 46 | return 47 | } 48 | 49 | func fetch(vulnerability models.Vulnerability, credentials *models.Credentials) models.Vulnerability { 50 | log.Printf("OpenAI: Enriching %s", vulnerability.Cve) 51 | prompt := generatePrompt(vulnerability) 52 | client := openai.NewClient(credentials.OpenAIAPIKey) 53 | resp, err := client.CreateChatCompletion( 54 | context.Background(), 55 | openai.ChatCompletionRequest{ 56 | Model: openai.GPT4Turbo20240409, 57 | Messages: []openai.ChatCompletionMessage{ 58 | { 59 | Role: openai.ChatMessageRoleUser, 60 | Content: prompt, 61 | }, 62 | }, 63 | }, 64 | ) 65 | 66 | if err != nil { 67 | log.Printf("ChatCompletion error: %v\n", err) //TODO: Need to pass the error back up the stack 68 | } 69 | vulnerability.Explanation = resp.Choices[0].Message.Content 70 | return vulnerability 71 | 72 | } 73 | 74 | func generatePrompt(vulnerability models.Vulnerability) (prompt string) { 75 | promptTemplate := ` 76 | Explain what {{ .Cve }} is and dig into: {{ .Description }} so it could be understood by a non-technical business user. 77 | ` 78 | tmpl, _ := template.New("prompt").Parse(promptTemplate) 79 | 80 | var resultBuffer bytes.Buffer 81 | _ = executeTemplate(&resultBuffer, tmpl, vulnerability) 82 | 83 | return resultBuffer.String() 84 | } 85 | 86 | func executeTemplate(buffer *bytes.Buffer, tmpl *template.Template, data interface{}) error { 87 | return tmpl.Execute(buffer, data) 88 | } 89 | -------------------------------------------------------------------------------- /filters/ignore.go: -------------------------------------------------------------------------------- 1 | // Package filters provides functionality to filter vulnerability output 2 | package filters 3 | 4 | import ( 5 | "github.com/devops-kung-fu/bomber/models" 6 | ) 7 | 8 | // Ignore goes through a list of vulnerabilities and ignores those that have a CVE listed in an ignore file 9 | func Ignore(vulnerabilities []models.Vulnerability, cves []string) (filtered []models.Vulnerability) { 10 | for i, v := range vulnerabilities { 11 | shouldAdd := true 12 | for _, cve := range cves { 13 | if v.ID == cve { 14 | shouldAdd = false 15 | } 16 | } 17 | if shouldAdd { 18 | filtered = append(filtered, vulnerabilities[i]) 19 | } 20 | } 21 | return 22 | } 23 | -------------------------------------------------------------------------------- /filters/ignore_test.go: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/devops-kung-fu/bomber/models" 9 | ) 10 | 11 | func TestIgnore(t *testing.T) { 12 | ignoreList := []string{"CVE-123", "CVE-456"} 13 | vulns := []models.Vulnerability{ 14 | { 15 | ID: "CVE-123", //should be removed 16 | Description: "Test 1", 17 | }, 18 | { 19 | ID: "CVE-789", 20 | Description: "Test 2", 21 | }, 22 | } 23 | result := Ignore(vulns, ignoreList) 24 | assert.Len(t, result, 1) 25 | 26 | moreVulns := []models.Vulnerability{ 27 | { 28 | ID: "CVE-321", 29 | Description: "Test 3", 30 | }, 31 | { 32 | ID: "CVE-987", 33 | Description: "Test 4", 34 | }, 35 | { 36 | ID: "CVE-456", //should be removed 37 | Description: "Test 5", 38 | }, 39 | } 40 | 41 | vulns = append(vulns, moreVulns...) 42 | result = Ignore(vulns, ignoreList) 43 | assert.Len(t, result, 3) 44 | 45 | for _, v := range result { 46 | assert.NotEqual(t, "CVE-123", v.ID) 47 | assert.NotEqual(t, "CVE-456", v.ID) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /filters/purl.go: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/package-url/packageurl-go" 7 | 8 | "github.com/devops-kung-fu/bomber/models" 9 | ) 10 | 11 | // Sanitize removes any invalid package URLs from the slice 12 | func Sanitize(purls []string) (sanitized []string, issues []models.Issue) { 13 | for _, p := range purls { 14 | purl, err := packageurl.FromString(p) 15 | if err != nil { 16 | //append a new models.Issue to the issues slice 17 | issues = append(issues, models.Issue{ 18 | IssueType: "InvalidPackageURL", 19 | Message: "Ignoring an invalid package URL", 20 | Purl: p, 21 | }) 22 | continue 23 | } 24 | err = purl.Normalize() 25 | if err != nil { 26 | //append a new models.Issue to the issues slice 27 | issues = append(issues, models.Issue{ 28 | IssueType: "InvalidPackageURL", 29 | Message: "Ignoring an invalid package URL", 30 | Purl: p, 31 | }) 32 | continue 33 | } 34 | if !strings.Contains(p, "file:") { 35 | if _, err := packageurl.FromString(p); err == nil { 36 | sanitized = append(sanitized, p) 37 | } 38 | } else { 39 | //append a new models.Issue to the issues slice 40 | issues = append(issues, models.Issue{ 41 | IssueType: "InvalidPackageURL", 42 | Message: "Ignoring an invalid package URL", 43 | Purl: p, 44 | }) 45 | } 46 | } 47 | return 48 | } 49 | 50 | // func sanitizePurl(input string) string { 51 | // re := regexp.MustCompile(`[^a-zA-Z0-9@/\.:-?=]+`) 52 | // sanitized := re.ReplaceAllString(input, "") 53 | 54 | // // Check if the sanitized string ends with a semantic version 55 | // semverPattern := `@\d+\.\d+\.\d+` 56 | // semverRegex := regexp.MustCompile(semverPattern) 57 | // if semverRegex.MatchString(sanitized) { 58 | // // Extract the semantic version 59 | // version := semverRegex.FindString(sanitized) 60 | // // Remove invalid characters before the version 61 | // sanitized = re.ReplaceAllString(strings.Split(sanitized, version)[0], "") + version 62 | // } else { 63 | // // Remove the at symbol and anything that follows it if no valid version is found 64 | // sanitized = strings.Split(sanitized, "@")[0] 65 | // } 66 | 67 | // return sanitized 68 | // } 69 | -------------------------------------------------------------------------------- /filters/purl_test.go: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/devops-kung-fu/bomber/models" 9 | ) 10 | 11 | func TestSanitize(t *testing.T) { 12 | // Input test data 13 | purls := []string{ 14 | "pkg:github.com/user/repo", 15 | "file:/path/to/file", 16 | "pkg:github.com/user/repo/file", 17 | "file:/path/to/another/file", 18 | } 19 | 20 | // Expected output 21 | expectedSanitized := []string{ 22 | "pkg:github.com/user/repo", 23 | "pkg:github.com/user/repo/file", 24 | } 25 | expectedIssues := []models.Issue{ 26 | { 27 | IssueType: "InvalidPackageURL", 28 | Message: "Ignoring an invalid package URL", 29 | Purl: "file:/path/to/file", 30 | }, 31 | { 32 | IssueType: "InvalidPackageURL", 33 | Message: "Ignoring an invalid package URL", 34 | Purl: "file:/path/to/another/file", 35 | }, 36 | } 37 | 38 | // Call the function 39 | sanitized, issues := Sanitize(purls) 40 | 41 | // Assert the results 42 | assert.ElementsMatch(t, expectedSanitized, sanitized) 43 | assert.ElementsMatch(t, expectedIssues, issues) 44 | } 45 | -------------------------------------------------------------------------------- /formats/cyclonedx/cyclonedx.go: -------------------------------------------------------------------------------- 1 | // Package cyclonedx provides additional functionality to interact with CycloneDX formatted SBOMs 2 | package cyclonedx 3 | 4 | import ( 5 | cyclone "github.com/CycloneDX/cyclonedx-go" 6 | ) 7 | 8 | // Purls returns a slice of Purls from a CycloneDX formatted SBOM 9 | func Purls(bom *cyclone.BOM) (purls []string) { 10 | for _, component := range *bom.Components { 11 | if component.PackageURL != "" { 12 | purls = append(purls, component.PackageURL) 13 | } 14 | } 15 | return 16 | } 17 | 18 | // Licenses returns a slice of strings that contain all of the licenses found in the SBOM 19 | func Licenses(bom *cyclone.BOM) (licenses []string) { 20 | for _, component := range *bom.Components { 21 | if component.Licenses != nil { 22 | for _, licenseChoice := range *component.Licenses { 23 | if licenseChoice.Expression != "" { 24 | licenses = append(licenses, licenseChoice.Expression) 25 | } 26 | if licenseChoice.License != nil && licenseChoice.License.ID != "" { 27 | licenses = append(licenses, licenseChoice.License.ID) 28 | } 29 | } 30 | } 31 | } 32 | return 33 | } 34 | 35 | // TestBytes creates a byte array containing a CycloneDX document used for testing 36 | func TestBytes() []byte { 37 | cycloneDXString := ` 38 | { 39 | "bomFormat": "CycloneDX", 40 | "specVersion": "1.4", 41 | "serialNumber": "urn:uuid:2c624d66-de7d-4ad3-b323-4037ff6ce352", 42 | "version": 1, 43 | "metadata": { 44 | "timestamp": "2022-09-06T17:45:39-06:00", 45 | "tools": [{ 46 | "vendor": "anchore", 47 | "name": "syft", 48 | "version": "[not provided]" 49 | }], 50 | "component": { 51 | "bom-ref": "af63bd4c8601b7f1", 52 | "type": "file", 53 | "name": "." 54 | } 55 | }, 56 | "components": [{ 57 | "bom-ref": "pkg:golang/github.com/cyclonedx/cyclonedx-go@v0.6.0?package-id=135cc8bc545c374", 58 | "type": "library", 59 | "name": "github.com/CycloneDX/cyclonedx-go", 60 | "version": "v0.6.0", 61 | "cpe": "cpe:2.3:a:CycloneDX:cyclonedx-go:v0.6.0:*:*:*:*:*:*:*", 62 | "purl": "pkg:golang/github.com/CycloneDX/cyclonedx-go@v0.6.0", 63 | "licenses": [ 64 | { 65 | "license": { 66 | "id": "MIT" 67 | } 68 | }, 69 | { 70 | "expression": "(AFL-2.1 OR BSD-3-Clause)" 71 | } 72 | ], 73 | "properties": [{ 74 | "name": "syft:package:metadataType", 75 | "value": "GolangBinMetadata" 76 | }] 77 | }] 78 | } 79 | ` 80 | return []byte(cycloneDXString) 81 | } 82 | -------------------------------------------------------------------------------- /formats/cyclonedx/cyclonedx_test.go: -------------------------------------------------------------------------------- 1 | package cyclonedx 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | cyclone "github.com/CycloneDX/cyclonedx-go" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestPurls(t *testing.T) { 12 | var sbom cyclone.BOM 13 | err := json.Unmarshal(TestBytes(), &sbom) 14 | assert.NoError(t, err) 15 | assert.NotNil(t, sbom) 16 | 17 | purls := Purls(&sbom) 18 | assert.Len(t, purls, 1) 19 | assert.Equal(t, "pkg:golang/github.com/CycloneDX/cyclonedx-go@v0.6.0", purls[0]) 20 | } 21 | 22 | func TestLicenses(t *testing.T) { 23 | var sbom cyclone.BOM 24 | err := json.Unmarshal(TestBytes(), &sbom) 25 | assert.NoError(t, err) 26 | assert.NotNil(t, sbom) 27 | 28 | licenses := Licenses(&sbom) 29 | 30 | assert.Len(t, licenses, 2) 31 | } 32 | -------------------------------------------------------------------------------- /formats/spdx/spdx.go: -------------------------------------------------------------------------------- 1 | // Package spdx provides functionality and structs to work with SPDX formatted SBOMs 2 | package spdx 3 | 4 | // BOM represents a SPDX Software Bill of Materials 5 | type BOM struct { 6 | SPDXVersion string `json:"spdxVersion"` 7 | DataLicense string `json:"dataLicense"` 8 | SPDXID string `json:"SPDXID"` 9 | DocumentName string `json:"documentName"` 10 | DocumentNamespace string `json:"documentNamespace"` 11 | ExternalDocumentRef string `json:"externalDocumentRef,omitempty"` 12 | DocumentComment string `json:"documentComment,omitempty"` 13 | Packages []Package `json:"packages"` 14 | Files []File `json:"files,omitempty"` 15 | Relationships []Relationship `json:"relationships,omitempty"` 16 | } 17 | 18 | // Package represents a component/package 19 | type Package struct { 20 | Name string `json:"name,omitempty"` 21 | SPDXID string `json:"SPDXID,omitempty"` 22 | VersionInfo string `json:"versionInfo,omitempty"` 23 | PackageFileName string `json:"packageFileName,omitempty"` 24 | Supplier string `json:"supplier,omitempty"` 25 | Originator string `json:"originator,omitempty"` 26 | DownloadLocation string `json:"downloadLocation,omitempty"` 27 | FilesAnalyzed bool `json:"filesAnalyzed,omitempty"` 28 | PackageVerificationCode struct { 29 | PackageVerificationCodeValue string `json:"packageVerificationCodeValue,omitempty"` 30 | PackageVerificationCodeExcludedFile string `json:"packageVerificationCodeExcludedFile,omitempty"` 31 | } `json:"packageVerificationCode,omitempty"` 32 | Checksum Checksum `json:"checksum,omitempty"` 33 | Homepage string `json:"homepage,omitempty"` 34 | SourceInfo string `json:"sourceInfo,omitempty"` 35 | LicenseConcluded string `json:"licenseConcluded,omitempty"` 36 | LicenseInfoFromFiles []string `json:"licenseInfoFromFiles,omitempty"` 37 | LicenseDeclared string `json:"licenseDeclared,omitempty"` 38 | CopyrightText string `json:"copyrightText,omitempty"` 39 | Summary string `json:"summary,omitempty"` 40 | Description string `json:"description,omitempty"` 41 | Comment string `json:"comment,omitempty"` 42 | ExternalRefs []ExternalRef `json:"externalRefs,omitempty"` 43 | AttributionText string `json:"attributionText,omitempty"` 44 | } 45 | 46 | // ExternalRef encapsulates various references such as a Purl. Wonky. 47 | type ExternalRef struct { 48 | ReferenceCategory string `json:"referenceCategory,omitempty"` 49 | ReferenceType string `json:"referenceType,omitempty"` 50 | ReferenceLocator string `json:"referenceLocator,omitempty"` 51 | Comment string `json:"comment,omitempty"` 52 | } 53 | 54 | // Checksum is used as a checksum on a package 55 | type Checksum struct { 56 | Algorithm string `json:"algorithm"` 57 | ChecksumValue string `json:"checksumValue"` 58 | } 59 | 60 | // File represents a scanned file, its licenses, and it'c checksum 61 | type File struct { 62 | SPDXID string `json:"SPDXID"` 63 | Checksums []Checksum `json:"checksums"` 64 | CopyrightText string `json:"copyrightText"` 65 | FileName string `json:"fileName"` 66 | FileTypes []string `json:"fileTypes"` 67 | LicenseConcluded string `json:"licenseConcluded"` 68 | LicenseInfoInFiles []string `json:"licenseInfoInFiles"` 69 | } 70 | 71 | // Relationship encapsulates a relationship from one SPDX element to another. Wonky. 72 | type Relationship struct { 73 | SpdxElementID string `json:"spdxElementId"` 74 | RelatedSpdxElement string `json:"relatedSpdxElement"` 75 | RelationshipType string `json:"relationshipType"` 76 | } 77 | 78 | // Purls returns a slice of Purls from a SPDX formatted SBOM 79 | func (bom *BOM) Purls() (purls []string) { 80 | for _, pkg := range bom.Packages { 81 | for _, extRef := range pkg.ExternalRefs { 82 | if extRef.ReferenceType == "purl" && extRef.ReferenceLocator != "" { 83 | purls = append(purls, extRef.ReferenceLocator) 84 | } 85 | } 86 | } 87 | return 88 | } 89 | 90 | // Licenses returns a slice of strings that contain all of the licenses found in the SBOM 91 | func (bom *BOM) Licenses() (licenses []string) { 92 | return 93 | } 94 | 95 | // TestBytes creates a byte array containing a SPDX document used for testing 96 | func TestBytes() []byte { 97 | SPDXString := ` 98 | { 99 | "SPDXID": "SPDXRef-DOCUMENT", 100 | "name": ".", 101 | "spdxVersion": "SPDX-2.2", 102 | "creationInfo": { 103 | "created": "2022-09-07T20:21:50.107518Z", 104 | "creators": [ 105 | "Organization: Anchore, Inc", 106 | "Tool: syft-[not provided]" 107 | ], 108 | "licenseListVersion": "3.18" 109 | }, 110 | "dataLicense": "CC0-1.0", 111 | "documentNamespace": "https://anchore.com/syft/dir/c29b2f20-5544-4f7b-9b70-3f44d5df98d2", 112 | "packages": [{ 113 | "SPDXID": "SPDXRef-135cc8bc545c374", 114 | "name": "github.com/CycloneDX/cyclonedx-go", 115 | "licenseConcluded": "NONE", 116 | "downloadLocation": "NOASSERTION", 117 | "externalRefs": [{ 118 | "referenceCategory": "PACKAGE_MANAGER", 119 | "referenceLocator": "pkg:golang/github.com/CycloneDX/cyclonedx-go@v0.6.0", 120 | "referenceType": "purl" 121 | }] 122 | }] 123 | }` 124 | return []byte(SPDXString) 125 | } 126 | -------------------------------------------------------------------------------- /formats/spdx/spdx_test.go: -------------------------------------------------------------------------------- 1 | package spdx 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestPurls(t *testing.T) { 11 | var sbom BOM 12 | err := json.Unmarshal(TestBytes(), &sbom) 13 | assert.NoError(t, err) 14 | assert.NotNil(t, sbom) 15 | 16 | purls := sbom.Purls() 17 | assert.Len(t, purls, 1) 18 | assert.Equal(t, "pkg:golang/github.com/CycloneDX/cyclonedx-go@v0.6.0", purls[0]) 19 | } 20 | 21 | func TestLicenses(t *testing.T) { 22 | var sbom BOM 23 | licenses := sbom.Licenses() 24 | 25 | assert.Len(t, licenses, 0) 26 | } 27 | -------------------------------------------------------------------------------- /formats/syft/syft.go: -------------------------------------------------------------------------------- 1 | // Package syft provides functionality and structs to work with syft formatted SBOMs 2 | package syft 3 | 4 | // BOM represents a Syft Software Bill of Materials 5 | type BOM struct { 6 | Artifacts []Artifact `json:"artifacts"` 7 | Schema Schema `json:"schema"` 8 | } 9 | 10 | // Artifact represents a component/package 11 | type Artifact struct { 12 | ID string `json:"id"` 13 | Name string `json:"name"` 14 | Version string `json:"version"` 15 | Type string `json:"type"` 16 | FoundBy string `json:"foundBy"` 17 | Locations []Location `json:"locations"` 18 | Licenses []interface{} `json:"licenses"` 19 | Language string `json:"language"` 20 | Cpes []string `json:"cpes"` 21 | Purl string `json:"purl"` 22 | MetadataType *string `json:"metadataType,omitempty"` 23 | Metadata *Metadata `json:"metadata,omitempty"` 24 | } 25 | 26 | // Location shows where the artifact is found/located 27 | type Location struct { 28 | Path string `json:"path"` 29 | } 30 | 31 | // Metadata describes basic information about the artifact 32 | type Metadata struct { 33 | GoBuildSettings map[string]string `json:"goBuildSettings,omitempty"` 34 | GoCompiledVersion string `json:"goCompiledVersion"` 35 | Architecture string `json:"architecture"` 36 | MainModule string `json:"mainModule"` 37 | H1Digest *string `json:"h1Digest,omitempty"` 38 | } 39 | 40 | // Schema provides detail about what JSON schema the document conforms to. Used by bomber to determine if the SBOM is in Syft format. 41 | type Schema struct { 42 | Version string `json:"version"` 43 | URL string `json:"url"` 44 | } 45 | 46 | // Purls returns a slice of Purls from a Syft formatted SBOM 47 | func (bom *BOM) Purls() (purls []string) { 48 | for _, artifact := range bom.Artifacts { 49 | if artifact.Purl != "" { 50 | purls = append(purls, artifact.Purl) 51 | } 52 | } 53 | return 54 | } 55 | 56 | // Licenses returns a slice of strings that contain all of the licenses found in the SBOM 57 | func (bom *BOM) Licenses() (licenses []string) { 58 | return 59 | } 60 | 61 | // TestBytes creates a byte array containing a Syft document used for testing 62 | func TestBytes() []byte { 63 | SPDXString := ` 64 | { 65 | "artifacts": [{ 66 | "id": "135cc8bc545c374", 67 | "name": "github.com/CycloneDX/cyclonedx-go", 68 | "version": "v0.6.0", 69 | "type": "go-module", 70 | "foundBy": "go-module-binary-cataloger", 71 | "locations": [{ 72 | "path": "bomber" 73 | }], 74 | "licenses": [], 75 | "language": "go", 76 | "cpes": [ 77 | "cpe:2.3:a:CycloneDX:cyclonedx-go:v0.6.0:*:*:*:*:*:*:*", 78 | "cpe:2.3:a:CycloneDX:cyclonedx_go:v0.6.0:*:*:*:*:*:*:*" 79 | ], 80 | "purl": "pkg:golang/github.com/CycloneDX/cyclonedx-go@v0.6.0", 81 | "metadataType": "GolangBinMetadata", 82 | "metadata": { 83 | "goCompiledVersion": "go1.19", 84 | "architecture": "amd64", 85 | "h1Digest": "h1:SizWGbZzFTC/O/1yh072XQBMxfvsoWqd//oKCIyzFyE=", 86 | "mainModule": "github.com/devops-kung-fu/bomber" 87 | } 88 | }], 89 | "schema": { 90 | "version": "3.3.2", 91 | "url": "https://raw.githubusercontent.com/anchore/syft/main/schema/json/schema-3.3.2.json" 92 | } 93 | }` 94 | return []byte(SPDXString) 95 | } 96 | -------------------------------------------------------------------------------- /formats/syft/syft_test.go: -------------------------------------------------------------------------------- 1 | package syft 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestPurls(t *testing.T) { 11 | var sbom BOM 12 | err := json.Unmarshal(TestBytes(), &sbom) 13 | assert.NoError(t, err) 14 | assert.NotNil(t, sbom) 15 | 16 | purls := sbom.Purls() 17 | assert.Len(t, purls, 1) 18 | assert.Equal(t, "pkg:golang/github.com/CycloneDX/cyclonedx-go@v0.6.0", purls[0]) 19 | } 20 | 21 | func TestLicenses(t *testing.T) { 22 | var sbom BOM 23 | licenses := sbom.Licenses() 24 | 25 | assert.Len(t, licenses, 0) 26 | } 27 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/devops-kung-fu/bomber 2 | 3 | go 1.22.10 4 | 5 | toolchain go1.23.2 6 | 7 | require ( 8 | github.com/CycloneDX/cyclonedx-go v0.9.2 9 | github.com/briandowns/spinner v1.23.1 10 | github.com/devops-kung-fu/common v0.2.6 11 | github.com/go-resty/resty/v2 v2.16.2 12 | github.com/gomarkdown/markdown v0.0.0-20241205020045-f7e15b2f3e62 13 | github.com/google/go-github v17.0.0+incompatible 14 | github.com/google/osv-scanner v1.9.2 15 | github.com/gookit/color v1.5.4 16 | github.com/jarcoal/httpmock v1.3.1 17 | github.com/jedib0t/go-pretty/v6 v6.6.5 18 | github.com/microcosm-cc/bluemonday v1.0.27 19 | github.com/package-url/packageurl-go v0.1.3 20 | github.com/remeh/sizedwaitgroup v1.0.0 21 | github.com/sashabaranov/go-openai v1.36.1 22 | github.com/spf13/afero v1.11.0 23 | github.com/spf13/cobra v1.8.1 24 | github.com/stretchr/testify v1.10.0 25 | ) 26 | 27 | require ( 28 | github.com/BurntSushi/toml v1.4.0 // indirect 29 | github.com/aymerick/douceur v0.2.0 // indirect 30 | github.com/davecgh/go-spew v1.1.1 // indirect 31 | github.com/fatih/color v1.18.0 // indirect 32 | github.com/google/go-querystring v1.1.0 // indirect 33 | github.com/gorilla/css v1.0.1 // indirect 34 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 35 | github.com/mattn/go-colorable v0.1.13 // indirect 36 | github.com/mattn/go-isatty v0.0.20 // indirect 37 | github.com/mattn/go-runewidth v0.0.16 // indirect 38 | github.com/pmezard/go-difflib v1.0.0 // indirect 39 | github.com/rivo/uniseg v0.4.7 // indirect 40 | github.com/spf13/pflag v1.0.5 // indirect 41 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 42 | golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect 43 | golang.org/x/mod v0.22.0 // indirect 44 | golang.org/x/net v0.34.0 // indirect 45 | golang.org/x/sync v0.10.0 // indirect 46 | golang.org/x/sys v0.29.0 // indirect 47 | golang.org/x/term v0.28.0 // indirect 48 | golang.org/x/text v0.21.0 // indirect 49 | gopkg.in/yaml.v3 v3.0.1 // indirect 50 | ) 51 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= 2 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/CycloneDX/cyclonedx-go v0.9.2 h1:688QHn2X/5nRezKe2ueIVCt+NRqf7fl3AVQk+vaFcIo= 4 | github.com/CycloneDX/cyclonedx-go v0.9.2/go.mod h1:vcK6pKgO1WanCdd61qx4bFnSsDJQ6SbM2ZuMIgq86Jg= 5 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 6 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 7 | github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= 8 | github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= 9 | github.com/briandowns/spinner v1.23.1 h1:t5fDPmScwUjozhDj4FA46p5acZWIPXYE30qW2Ptu650= 10 | github.com/briandowns/spinner v1.23.1/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= 11 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/devops-kung-fu/common v0.2.6 h1:HNL9suXELXHiSg7Ze0VinNkbngrBjovKYWPOckuarKc= 15 | github.com/devops-kung-fu/common v0.2.6/go.mod h1:ZLp6W5ewDWxmx45KF/Oj3IfJ3EhRALBkcfqLQnz23OU= 16 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 17 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 18 | github.com/gkampitakis/ciinfo v0.3.0 h1:gWZlOC2+RYYttL0hBqcoQhM7h1qNkVqvRCV1fOvpAv8= 19 | github.com/gkampitakis/ciinfo v0.3.0/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= 20 | github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= 21 | github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= 22 | github.com/gkampitakis/go-snaps v0.5.7 h1:uVGjHR4t4pPHU944udMx7VKHpwepZXmvDMF+yDmI0rg= 23 | github.com/gkampitakis/go-snaps v0.5.7/go.mod h1:ZABkO14uCuVxBHAXAfKG+bqNz+aa1bGPAg8jkI0Nk8Y= 24 | github.com/go-resty/resty/v2 v2.16.2 h1:CpRqTjIzq/rweXUt9+GxzzQdlkqMdt8Lm/fuK/CAbAg= 25 | github.com/go-resty/resty/v2 v2.16.2/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU= 26 | github.com/gomarkdown/markdown v0.0.0-20241205020045-f7e15b2f3e62 h1:pbAFUZisjG4s6sxvRJvf2N7vhpCvx2Oxb3PmS6pDO1g= 27 | github.com/gomarkdown/markdown v0.0.0-20241205020045-f7e15b2f3e62/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= 28 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 29 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 30 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 31 | github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= 32 | github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= 33 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 34 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 35 | github.com/google/osv-scanner v1.9.2 h1:N5Arl9SA75afbjmX8mKURgOIaKyuK3NUjCaxDlj1KHI= 36 | github.com/google/osv-scanner v1.9.2/go.mod h1:ZTL8Dp9z/7Jr9kkQSOGqo8z6Csqt83qMIr58aZVx+pM= 37 | github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= 38 | github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= 39 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 40 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 41 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 42 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 43 | github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= 44 | github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= 45 | github.com/jedib0t/go-pretty/v6 v6.6.5 h1:9PgMJOVBedpgYLI56jQRJYqngxYAAzfEUua+3NgSqAo= 46 | github.com/jedib0t/go-pretty/v6 v6.6.5/go.mod h1:Uq/HrbhuFty5WSVNfjpQQe47x16RwVGXIveNGEyGtHs= 47 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 48 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 49 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 50 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 51 | github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= 52 | github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= 53 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 54 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 55 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 56 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 57 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 58 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 59 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 60 | github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= 61 | github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= 62 | github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 63 | github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 64 | github.com/owenrumney/go-sarif/v2 v2.3.3 h1:ubWDJcF5i3L/EIOER+ZyQ03IfplbSU1BLOE26uKQIIU= 65 | github.com/owenrumney/go-sarif/v2 v2.3.3/go.mod h1:MSqMMx9WqlBSY7pXoOZWgEsVB4FDNfhcaXDA1j6Sr+w= 66 | github.com/package-url/packageurl-go v0.1.3 h1:4juMED3hHiz0set3Vq3KeQ75KD1avthoXLtmE3I0PLs= 67 | github.com/package-url/packageurl-go v0.1.3/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH2+mdJ2PJc2s50dQY0= 68 | github.com/pandatix/go-cvss v0.6.2 h1:TFiHlzUkT67s6UkelHmK6s1INKVUG7nlKYiWWDTITGI= 69 | github.com/pandatix/go-cvss v0.6.2/go.mod h1:jDXYlQBZrc8nvrMUVVvTG8PhmuShOnKrxP53nOFkt8Q= 70 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 71 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 72 | github.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E= 73 | github.com/remeh/sizedwaitgroup v1.0.0/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo= 74 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 75 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 76 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 77 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 78 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 79 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 80 | github.com/sashabaranov/go-openai v1.36.1 h1:EVfRXwIlW2rUzpx6vR+aeIKCK/xylSrVYAx1TMTSX3g= 81 | github.com/sashabaranov/go-openai v1.36.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= 82 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 83 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 84 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 85 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 86 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 87 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 88 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 89 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 90 | github.com/terminalstatic/go-xsd-validate v0.1.6 h1:TenYeQ3eY631qNi1/cTmLH/s2slHPRKTTHT+XSHkepo= 91 | github.com/terminalstatic/go-xsd-validate v0.1.6/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw= 92 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 93 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 94 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 95 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 96 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 97 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 98 | github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 99 | github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 100 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= 101 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= 102 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= 103 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= 104 | github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= 105 | github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= 106 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 107 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 108 | golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= 109 | golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= 110 | golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= 111 | golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 112 | golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 113 | golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 114 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 115 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 116 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 117 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 118 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 119 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 120 | golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= 121 | golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 122 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 123 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 124 | golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= 125 | golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 126 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 127 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 128 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 129 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 130 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 131 | -------------------------------------------------------------------------------- /img/bomber-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devops-kung-fu/bomber/9af27992666542f3ad7b2bdd8f340e4ba1a31b3f/img/bomber-example.png -------------------------------------------------------------------------------- /img/bomber-html-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devops-kung-fu/bomber/9af27992666542f3ad7b2bdd8f340e4ba1a31b3f/img/bomber-html-header.png -------------------------------------------------------------------------------- /img/bomber-html.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devops-kung-fu/bomber/9af27992666542f3ad7b2bdd8f340e4ba1a31b3f/img/bomber-html.png -------------------------------------------------------------------------------- /img/bomber-json.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devops-kung-fu/bomber/9af27992666542f3ad7b2bdd8f340e4ba1a31b3f/img/bomber-json.png -------------------------------------------------------------------------------- /img/bomber-readme-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devops-kung-fu/bomber/9af27992666542f3ad7b2bdd8f340e4ba1a31b3f/img/bomber-readme-logo.png -------------------------------------------------------------------------------- /img/bomber-social-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devops-kung-fu/bomber/9af27992666542f3ad7b2bdd8f340e4ba1a31b3f/img/bomber-social-banner.png -------------------------------------------------------------------------------- /img/bomber128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devops-kung-fu/bomber/9af27992666542f3ad7b2bdd8f340e4ba1a31b3f/img/bomber128x128.png -------------------------------------------------------------------------------- /img/bomber512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devops-kung-fu/bomber/9af27992666542f3ad7b2bdd8f340e4ba1a31b3f/img/bomber512x512.png -------------------------------------------------------------------------------- /img/providers/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devops-kung-fu/bomber/9af27992666542f3ad7b2bdd8f340e4ba1a31b3f/img/providers/banner.png -------------------------------------------------------------------------------- /img/providers/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devops-kung-fu/bomber/9af27992666542f3ad7b2bdd8f340e4ba1a31b3f/img/providers/github.png -------------------------------------------------------------------------------- /img/providers/osv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devops-kung-fu/bomber/9af27992666542f3ad7b2bdd8f340e4ba1a31b3f/img/providers/osv.png -------------------------------------------------------------------------------- /img/providers/snyk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devops-kung-fu/bomber/9af27992666542f3ad7b2bdd8f340e4ba1a31b3f/img/providers/snyk.png -------------------------------------------------------------------------------- /img/providers/sonatype.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devops-kung-fu/bomber/9af27992666542f3ad7b2bdd8f340e4ba1a31b3f/img/providers/sonatype.png -------------------------------------------------------------------------------- /img/sponsors/zero-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devops-kung-fu/bomber/9af27992666542f3ad7b2bdd8f340e4ba1a31b3f/img/sponsors/zero-logo.png -------------------------------------------------------------------------------- /lib/loader.go: -------------------------------------------------------------------------------- 1 | // Package lib contains core functionality to load Software Bill of Materials and contains common functions 2 | package lib 3 | 4 | import ( 5 | "bufio" 6 | "bytes" 7 | "crypto/sha256" 8 | "encoding/json" 9 | "fmt" 10 | "io" 11 | "log" 12 | "os" 13 | "path/filepath" 14 | 15 | cyclone "github.com/CycloneDX/cyclonedx-go" 16 | "github.com/devops-kung-fu/common/slices" 17 | "github.com/spf13/afero" 18 | 19 | cyclonedx "github.com/devops-kung-fu/bomber/formats/cyclonedx" 20 | spdx "github.com/devops-kung-fu/bomber/formats/spdx" 21 | syft "github.com/devops-kung-fu/bomber/formats/syft" 22 | "github.com/devops-kung-fu/bomber/models" 23 | ) 24 | 25 | type Loader struct { 26 | Afs *afero.Afero 27 | } 28 | 29 | // Load retrieves a slice of Purls from various types of SBOMs 30 | func (l *Loader) Load(args []string) (scanned []models.ScannedFile, purls []string, licenses []string, err error) { 31 | for _, arg := range args { 32 | isDir, err := l.Afs.IsDir(arg) 33 | if err != nil && arg != "-" { 34 | return scanned, purls, licenses, err 35 | } 36 | if isDir { 37 | s, values, lic, _ := l.loadFolderPurls(arg) 38 | scanned = append(scanned, s...) 39 | purls = append(purls, values...) 40 | licenses = append(licenses, lic...) 41 | } else { 42 | scanned, purls, licenses, _ = l.loadFilePurls(arg) 43 | } 44 | purls = slices.RemoveDuplicates(purls) 45 | licenses = slices.RemoveDuplicates(licenses) 46 | } 47 | return 48 | } 49 | 50 | func (l *Loader) loadFolderPurls(arg string) (scanned []models.ScannedFile, purls []string, licenses []string, err error) { 51 | absPath, _ := filepath.Abs(arg) 52 | files, err := l.Afs.ReadDir(absPath) 53 | for _, file := range files { 54 | path := filepath.Join(absPath, file.Name()) 55 | s, values, lic, err := l.loadFilePurls(path) 56 | if err != nil { 57 | log.Println(path, err) 58 | } 59 | scanned = append(scanned, s...) 60 | purls = append(purls, values...) 61 | licenses = append(licenses, lic...) 62 | } 63 | return 64 | } 65 | 66 | func (l *Loader) loadFilePurls(arg string) (scanned []models.ScannedFile, purls []string, licenses []string, err error) { 67 | b, err := l.readFile(arg) 68 | if err != nil { 69 | return scanned, nil, nil, err 70 | } 71 | 72 | scanned = append(scanned, models.ScannedFile{ 73 | Name: arg, 74 | SHA256: fmt.Sprintf("%x", sha256.Sum256(b)), 75 | }) 76 | 77 | if l.isCycloneDXXML(b) { 78 | log.Println("Detected CycloneDX XML") 79 | return l.processCycloneDX(cyclone.BOMFileFormatXML, b, scanned) 80 | } else if l.isCycloneDXJSON(b) { 81 | log.Println("Detected CycloneDX JSON") 82 | return l.processCycloneDX(cyclone.BOMFileFormatJSON, b, scanned) 83 | } else if l.isSPDX(b) { 84 | log.Println("Detected SPDX") 85 | var sbom spdx.BOM 86 | if err = json.Unmarshal(b, &sbom); err == nil { 87 | return scanned, sbom.Purls(), sbom.Licenses(), err 88 | } 89 | } else if l.isSyft(b) { 90 | log.Println("Detected Syft") 91 | var sbom syft.BOM 92 | if err = json.Unmarshal(b, &sbom); err == nil { 93 | return scanned, sbom.Purls(), sbom.Licenses(), err 94 | } 95 | } 96 | 97 | log.Printf("WARNING: %v isn't a valid SBOM", arg) 98 | log.Println(err) 99 | return scanned, nil, nil, fmt.Errorf("%v is not a SBOM recognized by bomber", arg) 100 | } 101 | 102 | func (l *Loader) readFile(arg string) ([]byte, error) { 103 | if arg == "-" { 104 | log.Printf("Reading from stdin") 105 | return io.ReadAll(bufio.NewReader(os.Stdin)) 106 | } 107 | log.Printf("Reading: %v", arg) 108 | return l.Afs.ReadFile(arg) 109 | } 110 | 111 | func (l *Loader) isCycloneDXXML(b []byte) bool { 112 | return bytes.Contains(b, []byte("xmlns")) && bytes.Contains(b, []byte("CycloneDX")) 113 | } 114 | 115 | func (l *Loader) isCycloneDXJSON(b []byte) bool { 116 | return bytes.Contains(b, []byte("bomFormat")) && bytes.Contains(b, []byte("CycloneDX")) 117 | } 118 | 119 | func (l *Loader) isSPDX(b []byte) bool { 120 | return bytes.Contains(b, []byte("SPDXRef-DOCUMENT")) 121 | } 122 | 123 | func (l *Loader) isSyft(b []byte) bool { 124 | return bytes.Contains(b, []byte("https://raw.githubusercontent.com/anchore/syft/main/schema/json/schema-")) 125 | } 126 | 127 | func (l *Loader) processCycloneDX(format cyclone.BOMFileFormat, b []byte, s []models.ScannedFile) (scanned []models.ScannedFile, purls []string, licenses []string, err error) { 128 | var sbom cyclone.BOM 129 | 130 | reader := bytes.NewReader(b) 131 | decoder := cyclone.NewBOMDecoder(reader, format) 132 | err = decoder.Decode(&sbom) 133 | if err == nil { 134 | return s, cyclonedx.Purls(&sbom), cyclonedx.Licenses(&sbom), err 135 | } 136 | return 137 | } 138 | 139 | // LoadIgnore loads a list of CVEs entered one on each line from the filename 140 | func (l *Loader) LoadIgnore(ignoreFile string) (cves []string, err error) { 141 | if ignoreFile == "" { 142 | return 143 | } 144 | log.Printf("Loading ignore file: %v\n", ignoreFile) 145 | exists, _ := l.Afs.Exists(ignoreFile) 146 | if !exists { 147 | log.Printf("ignore file not found: %v\n", ignoreFile) 148 | return nil, fmt.Errorf("ignore file not found: %v", ignoreFile) 149 | } 150 | log.Printf("ignore file found: %v\n", ignoreFile) 151 | f, _ := l.Afs.Open(ignoreFile) 152 | 153 | scanner := bufio.NewScanner(f) 154 | for scanner.Scan() { 155 | cves = append(cves, scanner.Text()) 156 | } 157 | 158 | return 159 | } 160 | -------------------------------------------------------------------------------- /lib/loader_test.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | cyclone "github.com/CycloneDX/cyclonedx-go" 8 | "github.com/spf13/afero" 9 | "github.com/stretchr/testify/assert" 10 | 11 | cyclonedx "github.com/devops-kung-fu/bomber/formats/cyclonedx" 12 | spdx "github.com/devops-kung-fu/bomber/formats/spdx" 13 | syft "github.com/devops-kung-fu/bomber/formats/syft" 14 | ) 15 | 16 | var ( 17 | afs *afero.Afero 18 | l Loader 19 | ) 20 | 21 | func SetupTest() { 22 | afs = &afero.Afero{Fs: afero.NewMemMapFs()} 23 | l = Loader{Afs: afs} 24 | } 25 | 26 | func Test_Load_cyclonedx(t *testing.T) { 27 | SetupTest() 28 | err := afs.WriteFile("/test-cyclonedx.json", cyclonedx.TestBytes(), 0644) 29 | assert.NoError(t, err) 30 | 31 | files, _ := afs.ReadDir("/") 32 | assert.Len(t, files, 1) 33 | 34 | scanned, purls, _, err := l.Load([]string{"/"}) 35 | 36 | assert.NotNil(t, scanned) 37 | assert.NoError(t, err) 38 | assert.Len(t, purls, 1) 39 | assert.Equal(t, "pkg:golang/github.com/CycloneDX/cyclonedx-go@v0.6.0", purls[0]) 40 | 41 | _, err = afs.ReadDir("/bad-dir") 42 | assert.Error(t, err) 43 | } 44 | 45 | func TestLoad_cyclonedx_stdin(t *testing.T) { 46 | SetupTest() 47 | tmpfile, err := os.CreateTemp("", "test-cyclonedx.json") 48 | assert.NoError(t, err) 49 | 50 | defer os.Remove(tmpfile.Name()) // clean up 51 | 52 | _, err = tmpfile.Write(cyclonedx.TestBytes()) 53 | assert.NoError(t, err) 54 | 55 | _, err = tmpfile.Seek(0, 0) 56 | assert.NoError(t, err) 57 | 58 | oldStdin := os.Stdin 59 | defer func() { os.Stdin = oldStdin }() // Restore original Stdin 60 | 61 | os.Stdin = tmpfile 62 | 63 | scanned, purls, _, err := l.Load([]string{"-"}) 64 | 65 | assert.NotNil(t, scanned) 66 | assert.NoError(t, err) 67 | assert.Len(t, purls, 1) 68 | assert.Equal(t, "pkg:golang/github.com/CycloneDX/cyclonedx-go@v0.6.0", purls[0]) 69 | 70 | err = tmpfile.Close() 71 | assert.NoError(t, err) 72 | } 73 | 74 | func Test_Load_SPDX(t *testing.T) { 75 | SetupTest() 76 | err := afs.WriteFile("/test-spdx.json", spdx.TestBytes(), 0644) 77 | assert.NoError(t, err) 78 | 79 | files, _ := afs.ReadDir("/") 80 | assert.Len(t, files, 1) 81 | 82 | scanned, purls, _, err := l.Load([]string{"/"}) 83 | 84 | assert.NotNil(t, scanned) 85 | assert.NoError(t, err) 86 | assert.Len(t, purls, 1) 87 | assert.Equal(t, "pkg:golang/github.com/CycloneDX/cyclonedx-go@v0.6.0", purls[0]) 88 | 89 | _, err = afs.ReadDir("/bad-dir") 90 | assert.Error(t, err) 91 | } 92 | 93 | func TestLoad_syft(t *testing.T) { 94 | SetupTest() 95 | err := afs.WriteFile("/test-syft.json", syft.TestBytes(), 0644) 96 | assert.NoError(t, err) 97 | 98 | files, _ := afs.ReadDir("/") 99 | assert.Len(t, files, 1) 100 | 101 | scanned, purls, _, err := l.Load([]string{"/"}) 102 | 103 | assert.NotNil(t, scanned) 104 | assert.NoError(t, err) 105 | assert.Len(t, purls, 1) 106 | assert.Equal(t, "pkg:golang/github.com/CycloneDX/cyclonedx-go@v0.6.0", purls[0]) 107 | 108 | _, err = afs.ReadDir("/bad-dir") 109 | assert.Error(t, err) 110 | } 111 | 112 | func Test_Load_BadJSON_SPDX(t *testing.T) { 113 | SetupTest() 114 | fudgedFile := spdx.TestBytes() 115 | bogusString := "bogus" 116 | fudgedFile = append(fudgedFile, bogusString...) 117 | 118 | err := afs.WriteFile("/test-spdx.json", fudgedFile, 0644) 119 | assert.NoError(t, err) 120 | 121 | _, _, _, err = l.loadFilePurls("/test-spdx.json") 122 | assert.Error(t, err) 123 | assert.Equal(t, "/test-spdx.json is not a SBOM recognized by bomber", err.Error()) 124 | } 125 | 126 | func TestLoad_garbage(t *testing.T) { 127 | SetupTest() 128 | err := afs.WriteFile("/not-a-sbom.json", []byte("test"), 0644) 129 | assert.NoError(t, err) 130 | 131 | _, _, _, err = l.loadFilePurls("/not-a-sbom.json") 132 | assert.Error(t, err) 133 | assert.Equal(t, "/not-a-sbom.json is not a SBOM recognized by bomber", err.Error()) 134 | } 135 | 136 | func Test_loadFilePurls(t *testing.T) { 137 | SetupTest() 138 | _, _, _, err := l.loadFilePurls("no-file.json") 139 | assert.Error(t, err) 140 | } 141 | 142 | func TestLoad_multiple_cyclonedx(t *testing.T) { 143 | SetupTest() 144 | err := afs.WriteFile("/test-cyclonedx.json", cyclonedx.TestBytes(), 0644) 145 | assert.NoError(t, err) 146 | 147 | err = afs.WriteFile("/test1/test1-cyclonedx.json", cyclonedx.TestBytes(), 0644) 148 | assert.NoError(t, err) 149 | 150 | err = afs.WriteFile("/test2/test2-cyclonedx.json", cyclonedx.TestBytes(), 0644) 151 | assert.NoError(t, err) 152 | 153 | scanned, purls, _, err := l.Load([]string{"/"}) 154 | 155 | assert.NotNil(t, scanned) 156 | assert.NoError(t, err) 157 | assert.Len(t, purls, 1) 158 | assert.Equal(t, "pkg:golang/github.com/CycloneDX/cyclonedx-go@v0.6.0", purls[0]) 159 | 160 | _, err = afs.ReadDir("/bad-dir") 161 | assert.Error(t, err) 162 | } 163 | 164 | func Test_LoadIgnore(t *testing.T) { 165 | SetupTest() 166 | _ = afs.WriteFile("test.ignore", []byte("test\ntest2"), 0644) 167 | 168 | cves, err := l.LoadIgnore("test.ignore") 169 | assert.NoError(t, err) 170 | assert.Len(t, cves, 2) 171 | 172 | _, err = l.LoadIgnore("tst.ignore") 173 | assert.Error(t, err) 174 | } 175 | 176 | func Test_LoadIgnoreData(t *testing.T) { 177 | SetupTest() 178 | 179 | err := afs.WriteFile("/.bomber.ignore", []byte("CVE-2022-31163"), 0644) 180 | assert.NoError(t, err) 181 | 182 | results, err := l.LoadIgnore("/.bomber.ignore") 183 | 184 | assert.NoError(t, err) 185 | assert.Len(t, results, 1) 186 | assert.Equal(t, results[0], "CVE-2022-31163") 187 | 188 | _, err = l.LoadIgnore("test") 189 | assert.Error(t, err) 190 | 191 | results, err = l.LoadIgnore("") 192 | assert.NoError(t, err) 193 | assert.Len(t, results, 0) 194 | } 195 | 196 | func Test_ProcessCycloneDX_InvalidFormat(t *testing.T) { 197 | 198 | invalidFile := []byte("{{") 199 | 200 | loader := Loader{} 201 | 202 | _, _, _, err := loader.processCycloneDX( 203 | cyclone.BOMFileFormatJSON, 204 | invalidFile, 205 | nil, 206 | ) 207 | 208 | assert.Error(t, err) 209 | } 210 | -------------------------------------------------------------------------------- /lib/scanner.go: -------------------------------------------------------------------------------- 1 | // Package lib contains core functionality to load Software Bill of Materials and contains common functions 2 | package lib 3 | 4 | import ( 5 | "fmt" 6 | "log" 7 | "slices" 8 | "strings" 9 | "time" 10 | 11 | "github.com/briandowns/spinner" 12 | "github.com/devops-kung-fu/common/util" 13 | "github.com/package-url/packageurl-go" 14 | "github.com/spf13/afero" 15 | 16 | "github.com/devops-kung-fu/bomber/enrichers" 17 | "github.com/devops-kung-fu/bomber/filters" 18 | "github.com/devops-kung-fu/bomber/models" 19 | ) 20 | 21 | // Scanner represents a vulnerability scanner. 22 | type Scanner struct { 23 | SeveritySummary models.Summary 24 | Credentials models.Credentials 25 | Renderers []models.Renderer 26 | Provider models.Provider 27 | Enrichment []string 28 | IgnoreFile string 29 | Severity string 30 | ExitCode bool 31 | Output string 32 | ProviderName string 33 | Version string 34 | Afs *afero.Afero 35 | } 36 | 37 | var loader Loader 38 | 39 | // Scan performs the vulnerability scan. 40 | func (s *Scanner) Scan(args []string) (exitCode int, err error) { 41 | loader = Loader{ 42 | s.Afs, 43 | } 44 | // Load packages and associated data 45 | scanned, purls, licenses, err := loader.Load(args) 46 | if err != nil { 47 | log.Print(err) 48 | return 49 | } 50 | if slices.Contains(s.Enrichment, "openai") { 51 | util.PrintWarning("OpenAI enrichment is experimental and may increase scanning time significantly") 52 | } 53 | if len(scanned) > 0 { 54 | if s.Output != "json" { 55 | util.PrintInfo("Scanning Files:") 56 | for _, f := range scanned { 57 | util.PrintTabbed(f.Name) 58 | } 59 | } 60 | } 61 | 62 | // If no packages are detected, print a message and return 63 | if len(purls) == 0 { 64 | util.PrintInfo("No packages were detected. Nothing has been scanned.") 65 | return 66 | } 67 | 68 | // Perform the package scan 69 | response, err := s.scanPackages(purls) 70 | if err != nil { 71 | return 1, err 72 | } 73 | 74 | // Process and output the scan results 75 | return s.processResults(scanned, licenses, response), nil 76 | } 77 | 78 | // scanPackages performs the core logic of scanning packages. 79 | func (s *Scanner) scanPackages(purls []string) (response []models.Package, err error) { 80 | // Detect and print information about ecosystems 81 | ecosystems := s.detectEcosystems(purls) 82 | spinner := spinner.New(spinner.CharSets[11], 100*time.Millisecond) 83 | 84 | // Sanitize package URLs and handle initial console output 85 | purls, issues := filters.Sanitize(purls) 86 | s.printHeader(len(purls), ecosystems, issues, spinner) 87 | 88 | // Perform the actual scan with the selected provider 89 | if s.Provider != nil { 90 | response, err = s.Provider.Scan(purls, &s.Credentials) 91 | if err != nil { 92 | return nil, err 93 | } 94 | } 95 | 96 | // Load ignore data if specified 97 | ignoredCVE, err := loader.LoadIgnore(s.IgnoreFile) 98 | if err != nil { 99 | util.PrintWarningf("Ignore flag set, but there was an error: %s", err) 100 | } 101 | 102 | for k, p := range response { 103 | if len(p.Vulnerabilities) == 0 { 104 | _ = slices.Delete(response, k, k) 105 | } 106 | } 107 | 108 | // Filter, enrich, and ignore vulnerabilities as needed 109 | s.filterVulnerabilities(response) 110 | s.ignoreVulnerabilities(response, ignoredCVE) 111 | s.enrichVulnerabilities(response) 112 | 113 | if s.Output != "json" { 114 | spinner.Stop() 115 | } 116 | 117 | return response, nil 118 | } 119 | 120 | // detectEcosystems detects the ecosystems from package URLs. 121 | func (s *Scanner) detectEcosystems(purls []string) []string { 122 | ecosystems := []string{} 123 | for _, p := range purls { 124 | purl, err := packageurl.FromString(p) 125 | if err == nil && !slices.Contains(ecosystems, purl.Type) { 126 | ecosystems = append(ecosystems, purl.Type) 127 | } 128 | } 129 | return ecosystems 130 | } 131 | 132 | // printHeader prints initial information about the scan. 133 | func (s *Scanner) printHeader(purlCount int, ecosystems []string, issues []models.Issue, spinner *spinner.Spinner) { 134 | if s.Output != "json" { 135 | util.PrintInfo("Ecosystems detected:", strings.Join(ecosystems, ",")) 136 | 137 | supportedEcosystems := s.Provider.SupportedEcosystems() 138 | if len(supportedEcosystems) > 0 { 139 | util.PrintInfo("Provider supported ecosystems: ", strings.Join(supportedEcosystems, ",")) 140 | } 141 | 142 | //if any ecosystems in the ecosytems slice are not supported by the provider, print a warning 143 | for _, e := range ecosystems { 144 | if !slices.Contains(supportedEcosystems, e) { 145 | util.PrintWarningf("Provider does not support detected ecosystem: %s\n", e) 146 | } 147 | } 148 | 149 | for _, issue := range issues { 150 | util.PrintWarningf("%v (%v)\n", issue.Message, issue.Purl) 151 | } 152 | 153 | util.PrintInfof("Scanning %v packages for vulnerabilities...\n", purlCount) 154 | util.PrintInfo("Vulnerability Provider:", s.getProviderInfo(), "\n") 155 | 156 | spinner.Suffix = fmt.Sprintf(" Fetching vulnerability data from %s", s.ProviderName) 157 | spinner.Start() 158 | } 159 | } 160 | 161 | func (s *Scanner) getProviderInfo() string { 162 | if s.Provider == nil { 163 | return "N/A" // or any other default value or message 164 | } 165 | return s.Provider.Info() 166 | } 167 | 168 | // filterVulnerabilities filters vulnerabilities based on severity. 169 | func (s *Scanner) filterVulnerabilities(response []models.Package) { 170 | if s.Severity != "" { 171 | for i, p := range response { 172 | vulns := []models.Vulnerability{} 173 | for _, v := range p.Vulnerabilities { 174 | fs := ParseSeverity(s.Severity) 175 | vs := ParseSeverity(v.Severity) 176 | if vs >= fs { 177 | vulns = append(vulns, v) 178 | } else { 179 | log.Printf("Removed vulnerability that was %s when the filter was %s", v.Severity, s.Severity) 180 | } 181 | } 182 | log.Printf("Filtered out %d vulnerabilities for package %s", len(p.Vulnerabilities)-len(vulns), p.Purl) 183 | response[i].Vulnerabilities = vulns 184 | } 185 | } 186 | } 187 | 188 | func (s *Scanner) ignoreVulnerabilities(response []models.Package, ignoredCVE []string) { 189 | for i, p := range response { 190 | if len(ignoredCVE) > 0 { 191 | filteredVulnerabilities := filters.Ignore(p.Vulnerabilities, ignoredCVE) 192 | response[i].Vulnerabilities = filteredVulnerabilities 193 | } 194 | } 195 | } 196 | 197 | // enrichAndIgnoreVulnerabilities enriches and ignores vulnerabilities as needed. 198 | func (s *Scanner) enrichVulnerabilities(response []models.Package) { 199 | epssEnricher, _ := enrichers.NewEnricher("epss") 200 | openaiEnricher, _ := enrichers.NewEnricher("openai") 201 | for i := range response { 202 | if slices.Contains(s.Enrichment, "epss") { 203 | response[i].Vulnerabilities, _ = epssEnricher.Enrich(response[i].Vulnerabilities, &s.Credentials) 204 | } 205 | } 206 | for i := range response { 207 | if slices.Contains(s.Enrichment, "openai") { 208 | response[i].Vulnerabilities, _ = openaiEnricher.Enrich(response[i].Vulnerabilities, &s.Credentials) 209 | } 210 | } 211 | } 212 | 213 | // processResults handles the final processing and output of scan results. 214 | func (s *Scanner) processResults(scanned []models.ScannedFile, licenses []string, response []models.Package) int { 215 | log.Println("Building severity summary") 216 | for _, r := range response { 217 | for _, v := range r.Vulnerabilities { 218 | AdjustSummary(v.Severity, &s.SeveritySummary) 219 | } 220 | } 221 | log.Println("Creating results") 222 | // Create results object 223 | results := models.NewResults(response, s.SeveritySummary, scanned, licenses, s.Version, s.ProviderName, s.Severity) 224 | 225 | // Render results using the specified renderer(s) 226 | for _, r := range s.Renderers { 227 | if err := r.Render(results); err != nil { 228 | log.Println(err) 229 | } 230 | } 231 | 232 | // Exit with code if required 233 | return s.exitWithCodeIfRequired(results) 234 | } 235 | 236 | // exitWithCodeIfRequired exits the program with the appropriate code based on severity. 237 | func (s *Scanner) exitWithCodeIfRequired(results models.Results) int { 238 | if s.ExitCode { 239 | code := highestSeverityExitCode(FlattenVulnerabilities(results.Packages)) 240 | log.Printf("fail severity: %d", code) 241 | return code 242 | } 243 | return 0 244 | } 245 | 246 | // HighestSeverityExitCode returns the exit code of the highest vulnerability 247 | func highestSeverityExitCode(vulnerabilities []models.Vulnerability) int { 248 | severityExitCodes := map[string]int{ 249 | "UNDEFINED": int(models.UNDEFINED), 250 | "LOW": int(models.LOW), 251 | "MODERATE": int(models.MODERATE), 252 | "HIGH": int(models.HIGH), 253 | "CRITICAL": int(models.CRITICAL), 254 | } 255 | 256 | highestSeverity := "UNDEFINED" // Initialize with the lowest severity 257 | for _, vulnerability := range vulnerabilities { 258 | if exitCode, ok := severityExitCodes[vulnerability.Severity]; ok { 259 | if exitCode > severityExitCodes[highestSeverity] { 260 | highestSeverity = vulnerability.Severity 261 | } 262 | } 263 | } 264 | 265 | return severityExitCodes[highestSeverity] 266 | } 267 | -------------------------------------------------------------------------------- /lib/scanner_test.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/devops-kung-fu/common/util" 7 | "github.com/spf13/afero" 8 | "github.com/stretchr/testify/assert" 9 | 10 | cyclonedx "github.com/devops-kung-fu/bomber/formats/cyclonedx" 11 | "github.com/devops-kung-fu/bomber/models" 12 | ) 13 | 14 | // MockProvider is a mock implementation of the Provider interface for testing purposes 15 | type MockProvider struct{} 16 | 17 | func (mp MockProvider) SupportedEcosystems() []string { 18 | return []string{"npm", "cargo", "golang"} 19 | } 20 | 21 | func (mp MockProvider) Scan(purls []string, credentials *models.Credentials) (packages []models.Package, err error) { 22 | return []models.Package{}, nil 23 | } 24 | 25 | // Info returns a mock provider info string 26 | func (mp MockProvider) Info() string { 27 | return "MockProviderInfo" 28 | } 29 | 30 | func Test_detectEcosystems(t *testing.T) { 31 | scanner := Scanner{} 32 | 33 | purls := []string{ 34 | "pkg:golang/github.com/test/test1@v1.19.0", 35 | "pkg:npm/github.com/test/test2@v1.19.0", 36 | "invalid_url", // This should be ignored 37 | } 38 | 39 | result := scanner.detectEcosystems(purls) 40 | 41 | assert.ElementsMatch(t, []string{"golang", "npm"}, result, "Detected ecosystems do not match expected result") 42 | } 43 | 44 | func TestScanner_Scan(t *testing.T) { 45 | output := util.CaptureOutput(func() { 46 | afs := &afero.Afero{Fs: afero.NewMemMapFs()} 47 | 48 | err := afs.WriteFile("/test-cyclonedx.json", cyclonedx.TestBytes(), 0644) 49 | assert.NoError(t, err) 50 | 51 | scanner := Scanner{ 52 | Output: "json", 53 | Afs: afs, 54 | } 55 | 56 | code, err := scanner.Scan([]string{}) 57 | assert.NoError(t, err) 58 | assert.Equal(t, 0, code) 59 | 60 | code, err = scanner.Scan([]string{"/test-cyclonedx.json"}) 61 | assert.NoError(t, err) 62 | assert.Equal(t, 0, code) 63 | 64 | // scanner.Output = "stdout" 65 | // code, err = scanner.Scan([]string{"/test-cyclonedx.json"}) 66 | // assert.NoError(t, err) 67 | // assert.Equal(t, 0, code) 68 | }) 69 | 70 | assert.NotNil(t, output) 71 | } 72 | 73 | func TestScanner_Scan_BadFileName(t *testing.T) { 74 | scanner := Scanner{ 75 | ExitCode: false, 76 | Afs: &afero.Afero{Fs: afero.NewMemMapFs()}, 77 | } 78 | _, err := scanner.Scan([]string{"test**.json"}) 79 | assert.Error(t, err) 80 | } 81 | 82 | func TestScanner_exitWithCodeIfRequired(t *testing.T) { 83 | scanner := Scanner{ 84 | ExitCode: false, 85 | } 86 | code := scanner.exitWithCodeIfRequired(models.Results{}) 87 | assert.Equal(t, 0, code) 88 | 89 | scanner.ExitCode = true 90 | code = scanner.exitWithCodeIfRequired(models.Results{}) 91 | assert.Equal(t, 10, code) 92 | } 93 | 94 | func TestScanner_enrichVulnerabilities(t *testing.T) { 95 | // Create a sample Scanner instance 96 | scanner := Scanner{} 97 | scanner.Enrichment = []string{"epss"} 98 | 99 | // Create a sample response with vulnerabilities 100 | response := []models.Package{ 101 | { 102 | Purl: "sample/package", 103 | Vulnerabilities: []models.Vulnerability{ 104 | {ID: "1", Title: "Vuln1", Cve: "CVE-2024-3094"}, 105 | {ID: "2", Title: "Vuln2", Cve: "CVE-2024-3094"}, 106 | }, 107 | }, 108 | } 109 | 110 | scanner.enrichVulnerabilities(response) 111 | 112 | assert.Len(t, response[0].Vulnerabilities, 2) 113 | assert.NotNil(t, response[0].Vulnerabilities[1].Epss) 114 | 115 | // t.Run("IgnoreVulnerabilities", func(t *testing.T) { 116 | // // Create a sample Scanner instance 117 | // scanner := Scanner{} 118 | 119 | // // Create a sample response with vulnerabilities 120 | // response := []models.Package{ 121 | // { 122 | // Purl: "sample/package", 123 | // Vulnerabilities: []models.Vulnerability{ 124 | // {ID: "1", Title: "Vuln1"}, 125 | // {ID: "2", Title: "Vuln2"}, 126 | // }, 127 | // }, 128 | // } 129 | 130 | // // Call the enrichAndIgnoreVulnerabilities method with ignoredCVE 131 | // scanner.ignoreVulnerabilities(response, []string{"1"}) 132 | 133 | // // Check if the specified vulnerabilities have been ignored 134 | // assert.Len(t, response[0].Vulnerabilities, 1) 135 | // assert.Equal(t, "Vuln2", response[0].Vulnerabilities[0].Title) 136 | // }) 137 | } 138 | 139 | func TestScanner_ignoreVulnerabilities(t *testing.T) { 140 | // Create a sample Scanner instance 141 | scanner := Scanner{} 142 | 143 | // Create a sample response with vulnerabilities 144 | response := []models.Package{ 145 | { 146 | Purl: "sample/package", 147 | Vulnerabilities: []models.Vulnerability{ 148 | {ID: "1", Title: "Vuln1"}, 149 | {ID: "2", Title: "Vuln2"}, 150 | }, 151 | }, 152 | } 153 | 154 | // Call the enrichAndIgnoreVulnerabilities method with ignoredCVE 155 | scanner.ignoreVulnerabilities(response, []string{"1"}) 156 | 157 | // Check if the specified vulnerabilities have been ignored 158 | assert.Len(t, response[0].Vulnerabilities, 1) 159 | assert.Equal(t, "Vuln2", response[0].Vulnerabilities[0].Title) 160 | 161 | } 162 | 163 | func TestFilterVulnerabilities(t *testing.T) { 164 | // Create a sample Scanner instance with a severity filter 165 | scanner := Scanner{Severity: "HIGH"} 166 | 167 | // Create a sample response with vulnerabilities 168 | response := []models.Package{ 169 | { 170 | Purl: "sample/package", 171 | Vulnerabilities: []models.Vulnerability{ 172 | {Severity: "LOW"}, 173 | {Severity: "MODERATE"}, 174 | {Severity: "HIGH"}, 175 | {Severity: "CRITICAL"}, 176 | }, 177 | }, 178 | { 179 | Purl: "another/package", 180 | Vulnerabilities: []models.Vulnerability{ 181 | {Severity: "LOW"}, 182 | {Severity: "HIGH"}, 183 | {Severity: "CRITICAL"}, 184 | }, 185 | }, 186 | } 187 | 188 | // Call the filterVulnerabilities method 189 | scanner.filterVulnerabilities(response) 190 | 191 | // Check if the vulnerabilities have been filtered correctly 192 | assert.Equal(t, "HIGH", response[0].Vulnerabilities[0].Severity) 193 | assert.Equal(t, 2, len(response[0].Vulnerabilities)) // Expecting other severities to be filtered out 194 | 195 | assert.Equal(t, "HIGH", response[1].Vulnerabilities[0].Severity) 196 | assert.Equal(t, "CRITICAL", response[1].Vulnerabilities[1].Severity) 197 | assert.Equal(t, 0, len(response[1].Vulnerabilities)-2) // Expecting LOW severity to be filtered out 198 | } 199 | 200 | func TestScannerGetProviderInfo(t *testing.T) { 201 | t.Run("WithMockProvider", func(t *testing.T) { 202 | scanner := Scanner{Provider: MockProvider{}} 203 | result := scanner.getProviderInfo() 204 | 205 | assert.Equal(t, "MockProviderInfo", result) 206 | }) 207 | 208 | t.Run("WithNilProvider", func(t *testing.T) { 209 | scanner := Scanner{Provider: nil} 210 | result := scanner.getProviderInfo() 211 | 212 | assert.Equal(t, "N/A", result) 213 | }) 214 | } 215 | 216 | func TestHighestSeverityExitCode(t *testing.T) { 217 | // Sample vulnerabilities with different severities 218 | vulnerabilities := []models.Vulnerability{ 219 | {Severity: "LOW"}, 220 | {Severity: "CRITICAL"}, 221 | {Severity: "MODERATE"}, 222 | {Severity: "HIGH"}, 223 | {Severity: "UNDEFINED"}, 224 | } 225 | 226 | // Calculate the expected exit code based on the highest severity 227 | expectedExitCode := 14 // CRITICAL has the highest severity 228 | 229 | // Call the function and check the result using assert 230 | actualExitCode := highestSeverityExitCode(vulnerabilities) 231 | assert.Equal(t, expectedExitCode, actualExitCode) 232 | } 233 | -------------------------------------------------------------------------------- /lib/util.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "reflect" 7 | "strings" 8 | "time" 9 | 10 | "github.com/gomarkdown/markdown" 11 | "github.com/microcosm-cc/bluemonday" 12 | 13 | "github.com/devops-kung-fu/bomber/models" 14 | ) 15 | 16 | // Rating takes a CVSS score as input and returns a rating string based on the score 17 | func Rating(score float64) string { 18 | switch { 19 | case score == 0.0: 20 | return "UNSPECIFIED" 21 | case score <= 3.9: 22 | return "LOW" 23 | case score <= 6.9: 24 | return "MODERATE" 25 | case score <= 8.9: 26 | return "HIGH" 27 | case score <= 10.0: 28 | return "CRITICAL" 29 | default: 30 | return "UNSPECIFIED" 31 | } 32 | } 33 | 34 | // AdjustSummary takes a severity string and a pointer to a Summary struct as input, and increments the corresponding severity count in the struct. 35 | func AdjustSummary(severity string, summary *models.Summary) { 36 | severity = strings.ToUpper(severity) 37 | 38 | switch severity { 39 | case "LOW": 40 | summary.Low++ 41 | case "MODERATE": 42 | summary.Moderate++ 43 | case "HIGH": 44 | summary.High++ 45 | case "CRITICAL": 46 | summary.Critical++ 47 | default: 48 | summary.Unspecified++ 49 | } 50 | } 51 | 52 | // ParseSeverity takes a severity string and returns an int 53 | func ParseSeverity(severity string) int { 54 | severity = strings.ToUpper(severity) 55 | switch severity { 56 | case "LOW": 57 | return int(models.LOW) 58 | case "MODERATE": 59 | return int(models.MODERATE) 60 | case "HIGH": 61 | return int(models.HIGH) 62 | case "CRITICAL": 63 | return int(models.CRITICAL) 64 | case "UNDEFINED": 65 | return int(models.UNDEFINED) 66 | default: 67 | return 0 68 | } 69 | } 70 | 71 | // FlattenVulnerabilities flattens all vulnerabilities for a package 72 | func FlattenVulnerabilities(packages []models.Package) []models.Vulnerability { 73 | var flattenedVulnerabilities []models.Vulnerability 74 | 75 | for _, pkg := range packages { 76 | flattenedVulnerabilities = append(flattenedVulnerabilities, pkg.Vulnerabilities...) 77 | } 78 | 79 | return flattenedVulnerabilities 80 | } 81 | 82 | // UniqueFieldValues returns a slice of unique field values from a slice of structs given a field name 83 | func UniqueFieldValues[T any](input []T, fieldName string) []interface{} { 84 | // Use a map to store unique field values 85 | fieldValuesMap := make(map[interface{}]struct{}) 86 | 87 | // Iterate through the input slice 88 | for _, item := range input { 89 | // Use reflection to get the struct's value 90 | value := reflect.ValueOf(item) 91 | 92 | // Check if the struct has the specified field 93 | if fieldValue := value.FieldByName(fieldName); fieldValue.IsValid() { 94 | // If the field exists, add its value to the map 95 | fieldValuesMap[fieldValue.Interface()] = struct{}{} 96 | } 97 | // If the field doesn't exist, do nothing 98 | 99 | } 100 | 101 | // Create a slice to store unique field values 102 | var uniqueFieldValuesSlice []interface{} 103 | 104 | // Iterate through the map keys and add them to the slice 105 | for fieldValue := range fieldValuesMap { 106 | uniqueFieldValuesSlice = append(uniqueFieldValuesSlice, fieldValue) 107 | } 108 | 109 | return uniqueFieldValuesSlice 110 | } 111 | 112 | // markdownToHTML converts the Markdown descriptions of vulnerabilities in 113 | // the given results to HTML. It uses the Blackfriday library to perform the 114 | // conversion and sanitizes the HTML using Bluemonday. 115 | func MarkdownToHTML(results models.Results) { 116 | for i := range results.Packages { 117 | for ii := range results.Packages[i].Vulnerabilities { 118 | md := []byte(results.Packages[i].Vulnerabilities[ii].Description) 119 | html := markdown.ToHTML(md, nil, nil) 120 | results.Packages[i].Vulnerabilities[ii].Description = string(bluemonday.UGCPolicy().SanitizeBytes(html)) 121 | 122 | md = []byte(results.Packages[i].Vulnerabilities[ii].Explanation) 123 | html = markdown.ToHTML(md, nil, nil) 124 | results.Packages[i].Vulnerabilities[ii].Explanation = string(bluemonday.UGCPolicy().SanitizeBytes(html)) 125 | } 126 | } 127 | } 128 | 129 | // generateFilename generates a unique filename based on the current timestamp 130 | // in the format "2006-01-02 15:04:05" and replaces certain characters to 131 | // create a valid filename. The resulting filename is a combination of the 132 | // timestamp and a fixed suffix. 133 | // TODO: Need to make this generic. It's only being used for HTML Renderers 134 | func GenerateFilename(format string) string { 135 | t := time.Now() 136 | r := strings.NewReplacer("-", "", " ", "-", ":", "-") 137 | return filepath.Join(".", fmt.Sprintf("%s-bomber-results.%s", r.Replace(t.Format("2006-01-02 15:04:05")), format)) 138 | } 139 | -------------------------------------------------------------------------------- /lib/util_test.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/devops-kung-fu/bomber/models" 10 | ) 11 | 12 | func Test_Rating(t *testing.T) { 13 | rating := 0.0 14 | result := Rating(rating) 15 | assert.Equal(t, "UNSPECIFIED", result) 16 | 17 | rating = 1.0 18 | result = Rating(rating) 19 | assert.Equal(t, "LOW", result) 20 | 21 | rating = 4.0 22 | result = Rating(rating) 23 | assert.Equal(t, "MODERATE", result) 24 | 25 | rating = 7.0 26 | result = Rating(rating) 27 | assert.Equal(t, "HIGH", result) 28 | 29 | rating = 9.0 30 | result = Rating(rating) 31 | assert.Equal(t, "CRITICAL", result) 32 | 33 | rating = 19.0 34 | result = Rating(rating) 35 | assert.Equal(t, "UNSPECIFIED", result) 36 | } 37 | 38 | func TestAdjustSummary(t *testing.T) { 39 | summary := models.Summary{} 40 | 41 | AdjustSummary("CRITICAL", &summary) 42 | AdjustSummary("HIGH", &summary) 43 | AdjustSummary("MODERATE", &summary) 44 | AdjustSummary("LOW", &summary) 45 | AdjustSummary("UNSPECIFIED", &summary) 46 | 47 | assert.Equal(t, summary.Critical, 1) 48 | assert.Equal(t, summary.High, 1) 49 | assert.Equal(t, summary.Moderate, 1) 50 | assert.Equal(t, summary.Low, 1) 51 | assert.Equal(t, summary.Unspecified, 1) 52 | 53 | AdjustSummary("UNSPECIFIED", &summary) 54 | assert.Equal(t, summary.Unspecified, 2) 55 | } 56 | 57 | func TestParseSeverity(t *testing.T) { 58 | t.Run("Valid severity: low", func(t *testing.T) { 59 | severity := "low" 60 | expected := 11 61 | result := ParseSeverity(severity) 62 | assert.Equal(t, expected, result) 63 | }) 64 | 65 | t.Run("Valid severity: moderate", func(t *testing.T) { 66 | severity := "moderate" 67 | expected := 12 68 | result := ParseSeverity(severity) 69 | assert.Equal(t, expected, result) 70 | }) 71 | 72 | t.Run("Valid severity: high", func(t *testing.T) { 73 | severity := "high" 74 | expected := 13 75 | result := ParseSeverity(severity) 76 | assert.Equal(t, expected, result) 77 | }) 78 | 79 | t.Run("Valid severity: critical", func(t *testing.T) { 80 | severity := "critical" 81 | expected := 14 82 | result := ParseSeverity(severity) 83 | assert.Equal(t, expected, result) 84 | }) 85 | 86 | t.Run("Invalid severity: invalid", func(t *testing.T) { 87 | severity := "invalid" 88 | expected := 0 89 | result := ParseSeverity(severity) 90 | assert.Equal(t, expected, result) 91 | }) 92 | 93 | t.Run("Mixed case severity: moderate", func(t *testing.T) { 94 | severity := "MoDerAte" 95 | expected := 12 96 | result := ParseSeverity(severity) 97 | assert.Equal(t, expected, result) 98 | }) 99 | 100 | t.Run("Valid severity: undefined", func(t *testing.T) { 101 | severity := "undefined" 102 | expected := 10 103 | result := ParseSeverity(severity) 104 | assert.Equal(t, expected, result) 105 | }) 106 | } 107 | 108 | func TestFlattenVulnerabilities(t *testing.T) { 109 | // Create some sample data for testing 110 | pkg1 := models.Package{ 111 | Purl: "pkg1", 112 | Vulnerabilities: []models.Vulnerability{ 113 | {DisplayName: "Vuln1", Severity: "LOW"}, 114 | {DisplayName: "Vuln2", Severity: "HIGH"}, 115 | }, 116 | } 117 | 118 | pkg2 := models.Package{ 119 | Purl: "pkg2", 120 | Vulnerabilities: []models.Vulnerability{ 121 | {DisplayName: "Vuln3", Severity: "MODERATE"}, 122 | }, 123 | } 124 | 125 | // Slice of Packages to test 126 | packages := []models.Package{pkg1, pkg2} 127 | 128 | // Call the FlattenVulnerabilities function 129 | flattenedVulnerabilities := FlattenVulnerabilities(packages) 130 | 131 | // Define the expected result 132 | expectedVulnerabilities := []models.Vulnerability{ 133 | {DisplayName: "Vuln1", Severity: "LOW"}, 134 | {DisplayName: "Vuln2", Severity: "HIGH"}, 135 | {DisplayName: "Vuln3", Severity: "MODERATE"}, 136 | } 137 | 138 | // Check if the actual result matches the expected result using assert.ElementsMatch 139 | assert.ElementsMatch(t, expectedVulnerabilities, flattenedVulnerabilities) 140 | } 141 | 142 | func Test_UniqueFieldValues(t *testing.T) { 143 | type TestStruct struct { 144 | CVE string 145 | // other properties... 146 | } 147 | structs := []TestStruct{ 148 | {CVE: "CVE-2021-1234"}, 149 | {CVE: "CVE-2021-5678"}, 150 | {CVE: "CVE-2021-1234"}, // Duplicate 151 | } 152 | 153 | // Get unique CVEs using the function 154 | uniqueCVEs := UniqueFieldValues(structs, "CVE") 155 | assert.Len(t, uniqueCVEs, 2) 156 | 157 | shouldBeNothing := UniqueFieldValues(structs, "ABC") 158 | assert.Len(t, shouldBeNothing, 0) 159 | } 160 | 161 | func Test_MarkdownToHTML(t *testing.T) { 162 | packages := []models.Package{ 163 | { 164 | Vulnerabilities: []models.Vulnerability{ 165 | { 166 | Description: "## test", 167 | }, 168 | }, 169 | }, 170 | } 171 | results := models.NewResults(packages, models.Summary{}, []models.ScannedFile{}, []string{"GPL"}, "0.0.0", "test", "") 172 | MarkdownToHTML(results) 173 | 174 | assert.NotNil(t, results) 175 | assert.Equal(t, "

test

\n", results.Packages[0].Vulnerabilities[0].Description) 176 | } 177 | 178 | func TestGenerateFilename(t *testing.T) { 179 | filename := GenerateFilename("html") 180 | 181 | pattern := `^\d{8}-\d{2}-\d{2}-\d{2}-bomber-results\.html$` 182 | 183 | assert.NotEqual(t, "", filename) 184 | assert.Regexp(t, regexp.MustCompile(pattern), filename) 185 | } 186 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Package main is the entry point for the bomber CLI. 2 | package main 3 | 4 | import ( 5 | "os" 6 | 7 | "github.com/devops-kung-fu/bomber/cmd" 8 | ) 9 | 10 | func main() { 11 | defer os.Exit(0) 12 | cmd.Execute() 13 | } 14 | -------------------------------------------------------------------------------- /models/constants.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type FailSeverity int 4 | 5 | const ( 6 | UNDEFINED FailSeverity = 10 7 | LOW FailSeverity = 11 8 | MODERATE FailSeverity = 12 9 | HIGH FailSeverity = 13 10 | CRITICAL FailSeverity = 14 11 | ) 12 | -------------------------------------------------------------------------------- /models/interfaces.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // Provider defines the methods that a provider must contain 4 | type Provider interface { 5 | SupportedEcosystems() []string 6 | Info() string 7 | Scan(purls []string, credentials *Credentials) (packages []Package, err error) 8 | } 9 | 10 | // Renderer defines the methods that a renderer must contain 11 | type Renderer interface { 12 | Render(results Results) error 13 | } 14 | 15 | // Enricher defines methods that can enrich a collection of vulnerabilities 16 | type Enricher interface { 17 | Enrich(vulnerabilities []Vulnerability, credentials *Credentials) (enriched []Vulnerability, err error) 18 | } 19 | -------------------------------------------------------------------------------- /models/structs.go: -------------------------------------------------------------------------------- 1 | // Package models contains structs and interfaces used by bomber 2 | package models 3 | 4 | import ( 5 | "time" 6 | ) 7 | 8 | // Package encapsulates information about a package/component and it's vulnerabilities 9 | type Package struct { 10 | Purl string `json:"coordinates"` 11 | Reference string `json:"reference,omitempty"` 12 | Description string `json:"description,omitempty"` 13 | Vulnerabilities []Vulnerability `json:"vulnerabilities"` 14 | } 15 | 16 | // Vulnerability encapsulates the information describing a detected vulnerability 17 | type Vulnerability struct { 18 | ID string `json:"id,omitempty"` 19 | DisplayName string `json:"displayName,omitempty"` 20 | Title string `json:"title,omitempty"` 21 | Description string `json:"description,omitempty"` 22 | Explanation string `json:"explanation,omitempty"` //This is an enrichment via OpenAI 23 | CvssScore float64 `json:"cvssScore,omitempty"` 24 | CvssVector string `json:"cvssVector,omitempty"` 25 | Cwe string `json:"cwe,omitempty"` 26 | Cve string `json:"cve,omitempty"` 27 | Reference string `json:"reference,omitempty"` 28 | ExternalReferences []interface{} `json:"externalReferences,omitempty"` 29 | Severity string `json:"severity,omitempty"` 30 | Epss EpssScore `json:"epss,omitempty"` 31 | } 32 | 33 | // Summary is a struct used to keep track of severity counts 34 | type Summary struct { 35 | Unspecified int 36 | Low int 37 | Moderate int 38 | High int 39 | Critical int 40 | } 41 | 42 | // Results is the high level JSON object used to define vulnerabilities processed by bomber. 43 | type Results struct { 44 | Meta Meta `json:"meta,omitempty"` 45 | Files []ScannedFile `json:"files,omitempty"` 46 | Licenses []string `json:"licenses,omitempty"` 47 | Summary Summary `json:"summary,omitempty"` 48 | Packages []Package `json:"packages,omitempty"` 49 | } 50 | 51 | // Meta contains system and execution information about the results from bomber 52 | type Meta struct { 53 | Generator string `json:"generator"` 54 | URL string `json:"url"` 55 | Version string `json:"version"` 56 | Provider string `json:"provider"` 57 | SeverityFilter string `json:"severityFilter"` 58 | Date time.Time `json:"date"` 59 | } 60 | 61 | // ScannedFile contains the absolute name and sha256 of a processed file 62 | type ScannedFile struct { 63 | Name string `json:"name"` 64 | SHA256 string `json:"sha256"` 65 | } 66 | 67 | // Credentials the user credentials used by a provider to authenticate to an API 68 | type Credentials struct { 69 | Username string 70 | ProviderToken string 71 | OpenAIAPIKey string 72 | } 73 | 74 | // NewResults defines the high level output of bomber 75 | func NewResults(packages []Package, summary Summary, scanned []ScannedFile, licenses []string, version, providerName string, severityFilter string) Results { 76 | return Results{ 77 | Meta: Meta{ 78 | Generator: "bomber", 79 | URL: "https://github.com/devops-kung-fu/bomber", 80 | Version: version, 81 | Provider: providerName, 82 | Date: time.Now(), 83 | SeverityFilter: severityFilter, 84 | }, 85 | Files: scanned, 86 | Summary: summary, 87 | Packages: packages, 88 | Licenses: licenses, 89 | } 90 | } 91 | 92 | // Epss encapsulates the response of a query to the Epss scoring API 93 | type Epss struct { 94 | Status string `json:"status,omitempty"` 95 | StatusCode int64 `json:"status-code,omitempty"` 96 | Version string `json:"version,omitempty"` 97 | Access string `json:"access,omitempty"` 98 | Total int64 `json:"total,omitempty"` 99 | Offset int64 `json:"offset,omitempty"` 100 | Limit int64 `json:"limit,omitempty"` 101 | Scores []EpssScore `json:"data,omitempty"` 102 | } 103 | 104 | // EpssScore contains epss score data for a specific CVE 105 | type EpssScore struct { 106 | Cve string `json:"cve,omitempty"` 107 | Epss string `json:"epss,omitempty"` 108 | Percentile string `json:"percentile,omitempty"` 109 | Date string `json:"date,omitempty"` 110 | } 111 | 112 | // Issue encapsulates an issue with the processing of an SBOM 113 | type Issue struct { 114 | Err error `json:"error,omitempty"` 115 | IssueType string `json:"issueType,omitempty"` 116 | Purl string `json:"purl,omitempty"` 117 | Message string `json:"message,omitempty"` 118 | } 119 | -------------------------------------------------------------------------------- /providers/gad/gad.go: -------------------------------------------------------------------------------- 1 | // Package gad contains functionality to retrieve vulnerability information from the GitHub Advisory Database 2 | package gad 3 | 4 | import ( 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | "os" 11 | "strings" 12 | "time" 13 | 14 | "github.com/go-resty/resty/v2" 15 | "github.com/package-url/packageurl-go" 16 | 17 | "github.com/devops-kung-fu/bomber/models" 18 | ) 19 | 20 | var client *resty.Client 21 | 22 | func init() { 23 | // Cloning the transport ensures a proper working http client that respects the proxy settings 24 | transport := http.DefaultTransport.(*http.Transport).Clone() 25 | transport.TLSHandshakeTimeout = 60 * time.Second 26 | client = resty.New().SetTransport(transport) 27 | } 28 | 29 | // Provider represents the OSSIndex provider 30 | type Provider struct{} 31 | 32 | // SupportedEcosystems returns a list of ecosystems supported by the Github Advisory Database 33 | func (Provider) SupportedEcosystems() []string { 34 | return []string{ 35 | "github-actions", 36 | "composer", 37 | "erlang", 38 | "golang", 39 | "maven", 40 | "npm", 41 | "nuget", 42 | "pypi", 43 | "rubygems", 44 | "cargo", 45 | } 46 | } 47 | 48 | // Info provides basic information about the GAD Provider 49 | func (Provider) Info() string { 50 | return "GitHub Advisory Database (https://github.com/advisories)" 51 | } 52 | 53 | func (Provider) Scan(purls []string, credentials *models.Credentials) (packages []models.Package, err error) { 54 | if err = validateCredentials(credentials); err != nil { 55 | return 56 | } 57 | 58 | for _, purl := range purls { 59 | response, e := queryGitHubAdvisories(purl, *credentials) 60 | if e != nil { 61 | return nil, e 62 | } 63 | pkg := models.Package{ 64 | Purl: purl, 65 | } 66 | for _, edge := range response.Data.SecurityVulnerabilities.Edges { 67 | log.Printf("Vulnerabilities for %s:\n", purl) 68 | //TODO: Add more information to the vulnerability struct and deduplicate 69 | vulnerability := models.Vulnerability{} 70 | 71 | advisory := edge.Node.Advisory 72 | vulnerability.DisplayName = advisory.Summary 73 | vulnerability.Description = advisory.Description 74 | vulnerability.Severity = advisory.Severity 75 | for _, identifier := range advisory.Identifiers { 76 | if identifier.Type == "CVE" { 77 | vulnerability.ID = identifier.Value 78 | vulnerability.Cve = identifier.Value 79 | vulnerability.Title = identifier.Value 80 | } 81 | } 82 | 83 | for _, identifier := range advisory.Identifiers { 84 | if identifier.Type == "CVE" { 85 | fmt.Printf("CVE: %s\n", identifier.Value) 86 | } 87 | } 88 | pkg.Vulnerabilities = append(pkg.Vulnerabilities, vulnerability) 89 | } 90 | if len(pkg.Vulnerabilities) > 0 { 91 | packages = append(packages, pkg) 92 | } 93 | } 94 | return 95 | } 96 | 97 | const githubGraphQLEndpoint = "https://api.github.com/graphql" 98 | 99 | type GraphQLRequest struct { 100 | Query string `json:"query"` 101 | Variables map[string]interface{} `json:"variables"` 102 | } 103 | 104 | type GraphQLResponse struct { 105 | Data struct { 106 | SecurityVulnerabilities struct { 107 | Edges []struct { 108 | Node struct { 109 | Advisory struct { 110 | Identifiers []struct { 111 | Type string `json:"type"` 112 | Value string `json:"value"` 113 | } `json:"identifiers"` 114 | Summary string `json:"summary"` 115 | Description string `json:"description"` 116 | Severity string `json:"severity"` 117 | } `json:"advisory"` 118 | } `json:"node"` 119 | } `json:"edges"` 120 | } `json:"securityVulnerabilities"` 121 | } `json:"data"` 122 | } 123 | 124 | func queryGitHubAdvisories(purl string, credentials models.Credentials) (*GraphQLResponse, error) { 125 | p, err := packageurl.FromString(purl) 126 | if err != nil { 127 | return nil, fmt.Errorf("invalid PURL: %v", err) 128 | } 129 | 130 | query := ` 131 | query($ecosystem: SecurityAdvisoryEcosystem!, $package: String!) { 132 | securityVulnerabilities(ecosystem: $ecosystem, package: $package, first: 100) { 133 | edges { 134 | node { 135 | advisory { 136 | identifiers { 137 | type 138 | value 139 | } 140 | summary 141 | description 142 | severity 143 | } 144 | } 145 | } 146 | } 147 | } 148 | ` 149 | 150 | variables := map[string]interface{}{ 151 | "ecosystem": strings.ToUpper(p.Type), 152 | "package": p.Name, 153 | } 154 | 155 | requestBody, err := json.Marshal(GraphQLRequest{Query: query, Variables: variables}) 156 | if err != nil { 157 | return nil, fmt.Errorf("error marshalling request: %v", err) 158 | } 159 | resp, _ := client.R(). 160 | SetBody(requestBody). 161 | SetAuthToken(credentials.ProviderToken). 162 | Post(githubGraphQLEndpoint) 163 | 164 | var graphQLResponse GraphQLResponse 165 | if resp.StatusCode() == http.StatusOK { 166 | err = json.Unmarshal(resp.Body(), &graphQLResponse) 167 | if err != nil { 168 | return nil, fmt.Errorf("error unmarshalling response: %v", err) 169 | } 170 | } else { 171 | return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode()) 172 | } 173 | 174 | return &graphQLResponse, nil 175 | } 176 | 177 | func validateCredentials(credentials *models.Credentials) (err error) { 178 | if credentials.ProviderToken == "" { 179 | credentials.ProviderToken = os.Getenv("GITHUB_TOKEN") 180 | } 181 | 182 | if credentials.ProviderToken == "" { 183 | err = errors.New("bomber requires an GitHub PAT to utilize the GitHub Advisory Database") 184 | } 185 | return 186 | } 187 | -------------------------------------------------------------------------------- /providers/gad/gad_test.go: -------------------------------------------------------------------------------- 1 | package gad 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestInfo(t *testing.T) { 11 | provider := Provider{} 12 | info := provider.Info() 13 | assert.Equal(t, "GitHub Advisory Database (https://github.com/advisories)", info) 14 | } 15 | 16 | func TestProvider_SupportedEcosystems(t *testing.T) { 17 | provider := Provider{} 18 | expectedEcosystems := []string{ 19 | "github-actions", 20 | "composer", 21 | "erlang", 22 | "golang", 23 | "maven", 24 | "npm", 25 | "nuget", 26 | "pypi", 27 | "rubygems", 28 | "cargo", 29 | } 30 | actualEcosystems := provider.SupportedEcosystems() 31 | assert.True(t, reflect.DeepEqual(expectedEcosystems, actualEcosystems), "Expected %v but got %v", expectedEcosystems, actualEcosystems) 32 | } 33 | -------------------------------------------------------------------------------- /providers/ossindex/OSSIndex.go: -------------------------------------------------------------------------------- 1 | // Package ossindex contains functionality to retrieve vulnerability information from Sonatype's OSSINDEX 2 | package ossindex 3 | 4 | import ( 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | "os" 11 | "time" 12 | 13 | "github.com/go-resty/resty/v2" 14 | 15 | "github.com/devops-kung-fu/bomber/lib" 16 | "github.com/devops-kung-fu/bomber/models" 17 | ) 18 | 19 | const ossindexURL = "https://ossindex.sonatype.org/api/v3/authorized/component-report" 20 | 21 | var client *resty.Client 22 | 23 | func init() { 24 | // Cloning the transport ensures a proper working http client that respects the proxy settings 25 | transport := http.DefaultTransport.(*http.Transport).Clone() 26 | transport.TLSHandshakeTimeout = 60 * time.Second 27 | client = resty.New().SetTransport(transport) 28 | } 29 | 30 | // Provider represents the OSSIndex provider 31 | type Provider struct{} 32 | 33 | func (Provider) SupportedEcosystems() []string { 34 | return []string{ 35 | "maven", 36 | "npm", 37 | "golang", 38 | "pypi", 39 | "nuget", 40 | "gem", 41 | "cargo", 42 | "pod", 43 | "composer", 44 | "conan", 45 | "conda", 46 | "cran", 47 | "rpm", 48 | "swift", 49 | } 50 | } 51 | 52 | // CoordinateRequest used for the request to the OSSIndex 53 | type CoordinateRequest struct { 54 | Coordinates []string `json:"coordinates"` 55 | } 56 | 57 | // Info provides basic information about the OSSIndexProvider 58 | func (Provider) Info() string { 59 | return "Sonatype OSS Index (https://ossindex.sonatype.org)" 60 | } 61 | 62 | // Scan scans a slice of Purls for vulnerabilities against the OSS Index 63 | func (Provider) Scan(purls []string, credentials *models.Credentials) (packages []models.Package, err error) { 64 | if err = validateCredentials(credentials); err != nil { 65 | return nil, fmt.Errorf("could not validate credentials: %w", err) 66 | } 67 | totalPurls := len(purls) 68 | for startIndex := 0; startIndex < totalPurls; startIndex += 128 { 69 | endIndex := startIndex + 128 70 | if endIndex > totalPurls { 71 | endIndex = totalPurls 72 | } 73 | p := purls[startIndex:endIndex] 74 | var coordinates CoordinateRequest 75 | coordinates.Coordinates = append(coordinates.Coordinates, p...) 76 | 77 | resp, _ := client.R(). 78 | SetBody(coordinates). 79 | SetBasicAuth(credentials.Username, credentials.ProviderToken). 80 | Post(ossindexURL) 81 | 82 | if resp.StatusCode() == http.StatusOK { 83 | var response []models.Package 84 | if err := json.Unmarshal(resp.Body(), &response); err != nil { 85 | log.Println("Error:", err) 86 | return nil, err 87 | } 88 | for i, pkg := range response { 89 | log.Println("Purl:", response[i].Purl) 90 | for ii := range response[i].Vulnerabilities { 91 | log.Println(response[i].Vulnerabilities[ii].ID) 92 | response[i].Vulnerabilities[ii].Severity = lib.Rating(response[i].Vulnerabilities[ii].CvssScore) 93 | } 94 | if len(pkg.Vulnerabilities) > 0 { 95 | packages = append(packages, response[i]) 96 | } 97 | } 98 | } else { 99 | log.Println("Error: unexpected status code. Skipping the batch: ", string(resp.Body())) 100 | } 101 | } 102 | return 103 | } 104 | 105 | func validateCredentials(credentials *models.Credentials) (err error) { 106 | if credentials == nil { 107 | return errors.New("credentials cannot be nil") 108 | } 109 | 110 | if credentials.Username == "" { 111 | credentials.Username = os.Getenv("BOMBER_PROVIDER_USERNAME") 112 | } 113 | 114 | if credentials.ProviderToken == "" { 115 | credentials.ProviderToken = os.Getenv("BOMBER_PROVIDER_TOKEN") 116 | } 117 | 118 | if credentials.Username == "" && credentials.ProviderToken == "" { 119 | err = errors.New("bomber requires a username and token to use the OSS Index provider") 120 | } 121 | return 122 | } 123 | -------------------------------------------------------------------------------- /providers/ossindex/OSSIndex_test.go: -------------------------------------------------------------------------------- 1 | package ossindex 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/jarcoal/httpmock" 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/devops-kung-fu/bomber/models" 11 | ) 12 | 13 | func TestInfo(t *testing.T) { 14 | provider := Provider{} 15 | info := provider.Info() 16 | assert.Equal(t, "Sonatype OSS Index (https://ossindex.sonatype.org)", info) 17 | } 18 | 19 | func Test_validateCredentials(t *testing.T) { 20 | // Back up any env tokens 21 | 22 | err := validateCredentials(nil) 23 | assert.Error(t, err) 24 | 25 | username := os.Getenv("BOMBER_PROVIDER_USERNAME") 26 | token := os.Getenv("BOMBER_PROVIDER_TOKEN") 27 | 28 | os.Unsetenv("BOMBER_PROVIDER_USERNAME") 29 | os.Unsetenv("BOMBER_PROVIDER_TOKEN") 30 | credentials := models.Credentials{ 31 | Username: "test", 32 | ProviderToken: "token", 33 | } 34 | 35 | err = validateCredentials(&credentials) 36 | assert.NoError(t, err) 37 | 38 | credentials.Username = "" 39 | credentials.ProviderToken = "" 40 | err = validateCredentials(&credentials) 41 | assert.Error(t, err) 42 | 43 | os.Setenv("BOMBER_PROVIDER_USERNAME", "test-env") 44 | os.Setenv("BOMBER_PROVIDER_TOKEN", "token-env") 45 | 46 | err = validateCredentials(&credentials) 47 | assert.NoError(t, err) 48 | assert.Equal(t, "test-env", credentials.Username) 49 | assert.Equal(t, "token-env", credentials.ProviderToken) 50 | 51 | //reset env 52 | os.Setenv("BOMBER_PROVIDER_USERNAME", username) 53 | os.Setenv("BOMBER_PROVIDER_TOKEN", token) 54 | } 55 | 56 | func TestProvider_Scan(t *testing.T) { 57 | 58 | credentials := models.Credentials{ 59 | Username: "test", 60 | ProviderToken: "test", 61 | } 62 | 63 | httpmock.ActivateNonDefault(client.GetClient()) 64 | defer httpmock.DeactivateAndReset() 65 | 66 | httpmock.RegisterResponder("POST", ossindexURL, 67 | httpmock.NewBytesResponder(200, ossTestResponse())) 68 | 69 | provider := Provider{} 70 | 71 | packages, err := provider.Scan([]string{"pkg:golang/github.com/briandowns/spinner@v1.19.0"}, &credentials) 72 | assert.NoError(t, err) 73 | assert.Equal(t, "pkg:gem/tzinfo@1.2.5", packages[0].Purl) 74 | assert.Len(t, packages[0].Vulnerabilities, 1) 75 | 76 | _, e := provider.Scan([]string{"pkg:golang/github.com/briandowns/spinner@v1.19.0"}, nil) 77 | assert.Error(t, e) 78 | 79 | httpmock.GetTotalCallCount() 80 | } 81 | 82 | func TestProvider_Scan_FakeCredentials(t *testing.T) { 83 | 84 | username := os.Getenv("BOMBER_PROVIDER_USERNAME") 85 | token := os.Getenv("BOMBER_PROVIDER_TOKEN") 86 | 87 | os.Unsetenv("BOMBER_PROVIDER_USERNAME") 88 | os.Unsetenv("BOMBER_PROVIDER_TOKEN") 89 | 90 | httpmock.Activate() 91 | defer httpmock.DeactivateAndReset() 92 | 93 | httpmock.RegisterResponder("POST", ossindexURL, 94 | httpmock.NewBytesResponder(402, nil)) 95 | 96 | credentials := models.Credentials{ 97 | Username: "", 98 | ProviderToken: "", 99 | } 100 | 101 | provider := Provider{} 102 | _, err := provider.Scan([]string{"pkg:golang/github.com/briandowns/spinner@v1.19.0"}, &credentials) 103 | assert.Error(t, err) 104 | 105 | os.Setenv("BOMBER_PROVIDER_USERNAME", username) 106 | os.Setenv("BOMBER_PROVIDER_TOKEN", token) 107 | } 108 | 109 | func ossTestResponse() []byte { 110 | response := ` 111 | [ 112 | { 113 | "coordinates": "pkg:gem/tzinfo@1.2.5", 114 | "description": "TZInfo provides daylight savings aware transformations between times in different time zones.", 115 | "reference": "https://ossindex.sonatype.org/component/pkg:gem/tzinfo@1.2.5?utm_source=mozilla&utm_medium=integration&utm_content=5.0", 116 | "vulnerabilities": [ 117 | { 118 | "id": "CVE-2022-31163", 119 | "displayName": "CVE-2022-31163", 120 | "title": "[CVE-2022-31163] CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')", 121 | "description": "TZInfo... ", 122 | "cvssScore": 8.1, 123 | "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H", 124 | "cwe": "CWE-22", 125 | "cve": "CVE-2022-31163", 126 | "reference": "https://ossindex.sonatype.org/vulnerability/CVE-2022-31163?component-type=gem&component-name=tzinfo&utm_source=mozilla&utm_medium=integration&utm_content=5.0", 127 | "externalReferences": [ 128 | "http://web.nvd.nist.gov/view/vuln/detail?vulnId=CVE-2022-31163", 129 | "https://github.com/tzinfo/tzinfo/releases/tag/v0.3.61", 130 | "https://github.com/tzinfo/tzinfo/releases/tag/v1.2.10", 131 | "https://github.com/tzinfo/tzinfo/security/advisories/GHSA-5cm2-9h8c-rvfx" 132 | ] 133 | } 134 | ] 135 | } 136 | ]` 137 | return []byte(response) 138 | } 139 | -------------------------------------------------------------------------------- /providers/osv/osv.go: -------------------------------------------------------------------------------- 1 | // Package osv contains functionality to retrieve vulnerability information from OSV.dev 2 | package osv 3 | 4 | import ( 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/go-resty/resty/v2" 12 | osvscanner "github.com/google/osv-scanner/pkg/osv" 13 | 14 | m "github.com/devops-kung-fu/bomber/models" 15 | ) 16 | 17 | const osvURL = "https://api.osv.dev/v1/query" 18 | 19 | var client *resty.Client 20 | 21 | func init() { 22 | // Cloning the transport ensures a proper working http client that respects the proxy settings 23 | transport := http.DefaultTransport.(*http.Transport).Clone() 24 | transport.TLSHandshakeTimeout = 60 * time.Second 25 | client = resty.New().SetTransport(transport) 26 | } 27 | 28 | // Provider represents the OSSIndex provider 29 | type Provider struct{} 30 | 31 | func (Provider) SupportedEcosystems() []string { 32 | return []string{ 33 | "almalinux", 34 | "alpine", 35 | "android", 36 | "bitnami", 37 | "cargo", 38 | "curl", 39 | "debian", 40 | "git", 41 | "github-actions", 42 | "go", 43 | "haskell", 44 | "hex", 45 | "linux", 46 | "maven", 47 | "npm", 48 | "nuget", 49 | "oss-fuzz", 50 | "packagist", 51 | "pub", 52 | "pypi", 53 | "python", 54 | "cran", 55 | "rocky", 56 | "rubygems", 57 | "swift", 58 | "ubuntu", 59 | } 60 | } 61 | 62 | // Info provides basic information about the OSVProvider 63 | func (Provider) Info() string { 64 | return "OSV Vulnerability Database (https://osv.dev)" 65 | } 66 | 67 | // Scan scans a lisst of Purls for vulnerabilities against OSV.dev. Note that credentials are not needed for OSV, so can be nil. 68 | func (Provider) Scan(purls []string, credentials *m.Credentials) ([]m.Package, error) { 69 | var query osvscanner.BatchedQuery 70 | for _, purl := range purls { 71 | purlQuery := osvscanner.MakePURLRequest(purl) 72 | query.Queries = append(query.Queries, purlQuery) 73 | } 74 | httpClient := client.GetClient() 75 | resp, err := osvscanner.MakeRequestWithClient(query, httpClient) 76 | if err != nil { 77 | return nil, fmt.Errorf("osv.dev batched request failed: %w", err) 78 | } 79 | 80 | hydrated, err := osvscanner.HydrateWithClient(resp, httpClient) 81 | 82 | if err != nil { 83 | return nil, fmt.Errorf("osv.dev hydration request failed: %w", err) 84 | } 85 | 86 | packages := []m.Package{} 87 | for i, r := range hydrated.Results { 88 | if len(r.Vulns) > 0 { 89 | pkg := m.Package{ 90 | Purl: query.Queries[i].Package.PURL, 91 | Vulnerabilities: []m.Vulnerability{}, 92 | } 93 | for _, vuln := range r.Vulns { 94 | severity, ok := vuln.DatabaseSpecific["severity"].(string) 95 | if !ok { 96 | severity = "UNSPECIFIED" 97 | } 98 | vulnerability := m.Vulnerability{ 99 | ID: func() string { 100 | for _, alias := range vuln.Aliases { 101 | if strings.HasPrefix(strings.ToLower(alias), "cve") { 102 | return alias 103 | } 104 | } 105 | if vuln.ID == "" { 106 | return "NOT PROVIDED" 107 | } 108 | return vuln.ID 109 | }(), 110 | Title: vuln.Summary, 111 | Description: vuln.Details, 112 | Severity: severity, 113 | Cve: func() string { 114 | if len(vuln.Aliases) > 0 { 115 | return strings.Join(vuln.Aliases, ",") 116 | } 117 | return "NOT PROVIDED" 118 | }(), 119 | CvssScore: func() float64 { 120 | s, ok := vuln.DatabaseSpecific["cvss_score"].(string) 121 | if ok { 122 | score, _ := strconv.ParseFloat(s, 64) 123 | return score 124 | } 125 | return 0.0 126 | }(), 127 | } 128 | if vulnerability.ID == "" && len(vuln.DatabaseSpecific["cwe_ids"].([]interface{})) > 0 { 129 | cweIDs := make([]string, len(vuln.DatabaseSpecific["cwe_ids"].([]interface{}))) 130 | for i, cweID := range vuln.DatabaseSpecific["cwe_ids"].([]interface{}) { 131 | cweIDs[i] = cweID.(string) 132 | } 133 | vulnerability.ID = strings.Join(cweIDs, ",") 134 | } 135 | pkg.Vulnerabilities = append(pkg.Vulnerabilities, vulnerability) 136 | } 137 | packages = append(packages, pkg) 138 | } 139 | } 140 | 141 | return packages, nil 142 | } 143 | -------------------------------------------------------------------------------- /providers/osv/osv_test.go: -------------------------------------------------------------------------------- 1 | package osv 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestInfo(t *testing.T) { 10 | provider := Provider{} 11 | info := provider.Info() 12 | assert.Equal(t, "OSV Vulnerability Database (https://osv.dev)", info) 13 | } 14 | 15 | // func TestProvider_Scan(t *testing.T) { 16 | // httpmock.ActivateNonDefault(client.GetClient()) 17 | // defer httpmock.DeactivateAndReset() 18 | 19 | // httpmock.RegisterResponder("POST", osvURL, 20 | // httpmock.NewBytesResponder(200, osvTestResponse())) 21 | 22 | // provider := Provider{} 23 | 24 | // packages, err := provider.Scan([]string{"pkg:golang/github.com/briandowns/spinner@v1.19.0"}, nil) 25 | // assert.NoError(t, err) 26 | // assert.Equal(t, "pkg:golang/github.com/briandowns/spinner@v1.19.0", packages[0].Purl) 27 | // assert.Len(t, packages[0].Vulnerabilities, 1) 28 | // httpmock.GetTotalCallCount() 29 | // } 30 | 31 | // func TestProvider_BadResponse(t *testing.T) { 32 | // httpmock.ActivateNonDefault(client.GetClient()) 33 | // defer httpmock.DeactivateAndReset() 34 | 35 | // httpmock.RegisterResponder("POST", osvURL, 36 | // httpmock.NewBytesResponder(500, []byte{})) 37 | 38 | // provider := Provider{} 39 | // _, err := provider.Scan([]string{"pkg:golang/github.com/briandowns/spinner@v1.19.0"}, nil) 40 | // assert.Error(t, err) 41 | // assert.Contains(t, err.Error(), "unexpected status code") 42 | // } 43 | 44 | // func osvTestResponse() []byte { 45 | // response := ` 46 | // { 47 | // "vulns": [{ 48 | // "id": "GHSA-462w-v97r-4m45", 49 | // "summary": "High severity vulnerability that affects Jinja2", 50 | // "details": "In Pallets Jinja before 2.10.1, str.format_map allows a sandbox escape.", 51 | // "aliases": [ 52 | // "CVE-2019-10906" 53 | // ], 54 | // "modified": "2022-08-15T08:49:16.398254Z", 55 | // "published": "2019-04-10T14:30:24Z", 56 | // "database_specific": { 57 | // "cwe_ids": [], 58 | // "severity": "HIGH", 59 | // "github_reviewed": true 60 | // }, 61 | // "references": [{ 62 | // "type": "ADVISORY", 63 | // "url": "https://nvd.nist.gov/vuln/detail/CVE-2019-10906" 64 | // }, 65 | // { 66 | // "type": "WEB", 67 | // "url": "https://access.redhat.com/errata/RHSA-2019:1152" 68 | // }, 69 | // { 70 | // "type": "WEB", 71 | // "url": "https://access.redhat.com/errata/RHSA-2019:1237" 72 | // }, 73 | // { 74 | // "type": "WEB", 75 | // "url": "https://access.redhat.com/errata/RHSA-2019:1329" 76 | // }, 77 | // { 78 | // "type": "ADVISORY", 79 | // "url": "https://github.com/advisories/GHSA-462w-v97r-4m45" 80 | // }, 81 | // { 82 | // "type": "WEB", 83 | // "url": "https://lists.apache.org/thread.html/09fc842ff444cd43d9d4c510756fec625ef8eb1175f14fd21de2605f@%3Cdevnull.infra.apache.org%3E" 84 | // }, 85 | // { 86 | // "type": "WEB", 87 | // "url": "https://lists.apache.org/thread.html/2b52b9c8b9d6366a4f1b407a8bde6af28d9fc73fdb3b37695fd0d9ac@%3Cdevnull.infra.apache.org%3E" 88 | // }, 89 | // { 90 | // "type": "WEB", 91 | // "url": "https://lists.apache.org/thread.html/320441dccbd9a545320f5f07306d711d4bbd31ba43dc9eebcfc602df@%3Cdevnull.infra.apache.org%3E" 92 | // }, 93 | // { 94 | // "type": "WEB", 95 | // "url": "https://lists.apache.org/thread.html/46c055e173b52d599c648a98199972dbd6a89d2b4c4647b0500f2284@%3Cdevnull.infra.apache.org%3E" 96 | // }, 97 | // { 98 | // "type": "WEB", 99 | // "url": "https://lists.apache.org/thread.html/57673a78c4d5c870d3f21465c7e2946b9f8285c7c57e54c2ae552f02@%3Ccommits.airflow.apache.org%3E" 100 | // }, 101 | // { 102 | // "type": "WEB", 103 | // "url": "https://lists.apache.org/thread.html/7f39f01392d320dfb48e4901db68daeece62fd60ef20955966739993@%3Ccommits.airflow.apache.org%3E" 104 | // }, 105 | // { 106 | // "type": "WEB", 107 | // "url": "https://lists.apache.org/thread.html/b2380d147b508bbcb90d2cad443c159e63e12555966ab4f320ee22da@%3Ccommits.airflow.apache.org%3E" 108 | // }, 109 | // { 110 | // "type": "WEB", 111 | // "url": "https://lists.apache.org/thread.html/f0c4a03418bcfe70c539c5dbaf99c04c98da13bfa1d3266f08564316@%3Ccommits.airflow.apache.org%3E" 112 | // }, 113 | // { 114 | // "type": "WEB", 115 | // "url": "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/DSW3QZMFVVR7YE3UT4YRQA272TYAL5AF/" 116 | // }, 117 | // { 118 | // "type": "WEB", 119 | // "url": "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/QCDYIS254EJMBNWOG4S5QY6AOTOR4TZU/" 120 | // }, 121 | // { 122 | // "type": "WEB", 123 | // "url": "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/TS7IVZAJBWOHNRDMFJDIZVFCMRP6YIUQ/" 124 | // }, 125 | // { 126 | // "type": "WEB", 127 | // "url": "https://palletsprojects.com/blog/jinja-2-10-1-released" 128 | // }, 129 | // { 130 | // "type": "WEB", 131 | // "url": "https://usn.ubuntu.com/4011-1/" 132 | // }, 133 | // { 134 | // "type": "WEB", 135 | // "url": "https://usn.ubuntu.com/4011-2/" 136 | // }, 137 | // { 138 | // "type": "WEB", 139 | // "url": "http://lists.opensuse.org/opensuse-security-announce/2019-05/msg00030.html" 140 | // }, 141 | // { 142 | // "type": "WEB", 143 | // "url": "http://lists.opensuse.org/opensuse-security-announce/2019-06/msg00064.html" 144 | // } 145 | // ], 146 | // "affected": [{ 147 | // "package": { 148 | // "name": "jinja2", 149 | // "ecosystem": "PyPI", 150 | // "purl": "pkg:pypi/jinja2" 151 | // }, 152 | // "ranges": [{ 153 | // "type": "ECOSYSTEM", 154 | // "events": [{ 155 | // "introduced": "0" 156 | // }, 157 | // { 158 | // "fixed": "2.10.1" 159 | // } 160 | // ] 161 | // }], 162 | // "versions": [ 163 | // "2.0", 164 | // "2.0rc1", 165 | // "2.1", 166 | // "2.1.1", 167 | // "2.10", 168 | // "2.2", 169 | // "2.2.1", 170 | // "2.3", 171 | // "2.3.1", 172 | // "2.4", 173 | // "2.4.1", 174 | // "2.5", 175 | // "2.5.1", 176 | // "2.5.2", 177 | // "2.5.3", 178 | // "2.5.4", 179 | // "2.5.5", 180 | // "2.6", 181 | // "2.7", 182 | // "2.7.1", 183 | // "2.7.2", 184 | // "2.7.3", 185 | // "2.8", 186 | // "2.8.1", 187 | // "2.9", 188 | // "2.9.1", 189 | // "2.9.2", 190 | // "2.9.3", 191 | // "2.9.4", 192 | // "2.9.5", 193 | // "2.9.6" 194 | // ], 195 | // "database_specific": { 196 | // "source": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2019/04/GHSA-462w-v97r-4m45/GHSA-462w-v97r-4m45.json" 197 | // } 198 | // }], 199 | // "schema_version": "1.2.0", 200 | // "severity": [{ 201 | // "type": "CVSS_V3", 202 | // "score": "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N" 203 | // }] 204 | // } 205 | 206 | // ] 207 | // }` 208 | // return []byte(response) 209 | // } 210 | -------------------------------------------------------------------------------- /providers/providerfactory.go: -------------------------------------------------------------------------------- 1 | // Package providers contains functionality to retrieve vulnerability data from different external sources 2 | package providers 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/devops-kung-fu/bomber/models" 8 | "github.com/devops-kung-fu/bomber/providers/gad" 9 | "github.com/devops-kung-fu/bomber/providers/ossindex" 10 | "github.com/devops-kung-fu/bomber/providers/osv" 11 | "github.com/devops-kung-fu/bomber/providers/snyk" 12 | ) 13 | 14 | // NewProvider will return a provider interface for the requested vulnerability services 15 | func NewProvider(name string) (provider models.Provider, err error) { 16 | switch name { 17 | case "ossindex": 18 | provider = ossindex.Provider{} 19 | case "osv": 20 | provider = osv.Provider{} 21 | case "snyk": 22 | provider = snyk.Provider{} 23 | case "github": 24 | provider = gad.Provider{} 25 | default: 26 | err = fmt.Errorf("%s is not a valid provider type", name) 27 | } 28 | return 29 | } 30 | -------------------------------------------------------------------------------- /providers/providerfactory_test.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/devops-kung-fu/bomber/providers/gad" 9 | "github.com/devops-kung-fu/bomber/providers/ossindex" 10 | "github.com/devops-kung-fu/bomber/providers/osv" 11 | "github.com/devops-kung-fu/bomber/providers/snyk" 12 | ) 13 | 14 | func TestNewProvider(t *testing.T) { 15 | provider, err := NewProvider("ossindex") 16 | assert.NoError(t, err) 17 | assert.IsType(t, ossindex.Provider{}, provider) 18 | 19 | provider, err = NewProvider("osv") 20 | assert.NoError(t, err) 21 | assert.IsType(t, osv.Provider{}, provider) 22 | 23 | provider, err = NewProvider("snyk") 24 | assert.NoError(t, err) 25 | assert.IsType(t, snyk.Provider{}, provider) 26 | 27 | provider, err = NewProvider("github") 28 | assert.NoError(t, err) 29 | assert.IsType(t, gad.Provider{}, provider) 30 | 31 | _, err = NewProvider("test") 32 | assert.Error(t, err) 33 | } 34 | -------------------------------------------------------------------------------- /providers/snyk/jsonapi.go: -------------------------------------------------------------------------------- 1 | package snyk 2 | 3 | type JSONAPI struct { 4 | // Version of the JSON API specification this server supports. 5 | Version string `json:"version"` 6 | } 7 | 8 | type LinkProperty interface{} 9 | 10 | type Links struct { 11 | PaginatedLinks 12 | Related *LinkProperty `json:"related,omitempty"` 13 | } 14 | 15 | type PaginatedLinks struct { 16 | First *LinkProperty `json:"first,omitempty"` 17 | Last *LinkProperty `json:"last,omitempty"` 18 | Next *LinkProperty `json:"next,omitempty"` 19 | Prev *LinkProperty `json:"prev,omitempty"` 20 | Self *LinkProperty `json:"self,omitempty"` 21 | } 22 | 23 | // Meta Free-form object that may contain non-standard information. 24 | type Meta struct { 25 | AdditionalProperties map[string]interface{} `json:"-"` 26 | } 27 | 28 | type PackageMeta struct { 29 | // The package’s name 30 | Name string `json:"name,omitempty"` 31 | 32 | // A name prefix, such as a maven group id or docker image owner 33 | Namespace string `json:"namespace,omitempty"` 34 | 35 | // The package type or protocol 36 | Type string `json:"type,omitempty"` 37 | 38 | // The purl of the package 39 | URL string `json:"url,omitempty"` 40 | 41 | // The version of the package 42 | Version string `json:"version,omitempty"` 43 | } 44 | -------------------------------------------------------------------------------- /providers/snyk/orgid.go: -------------------------------------------------------------------------------- 1 | package snyk 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/go-resty/resty/v2" 10 | ) 11 | 12 | type selfDocument struct { 13 | Data struct { 14 | Attributes struct { 15 | AvatarURL string `json:"avatar_url,omitempty"` 16 | DefaultOrgContext string `json:"default_org_context,omitempty"` 17 | Name string `json:"name,omitempty"` 18 | Username string `json:"username,omitempty"` 19 | } `json:"attributes,omitempty"` 20 | ID string `json:"id,omitempty"` 21 | Type string `json:"type,omitempty"` 22 | } 23 | Jsonapi JSONAPI `json:"jsonapi,omitempty"` 24 | Links PaginatedLinks `json:"links,omitempty"` 25 | } 26 | 27 | func getOrgID(token string) (orgID string, err error) { 28 | client := resty.New() 29 | client.Debug = true 30 | 31 | resp, err := client.R(). 32 | SetHeader("User-Agent", "bomber"). 33 | SetAuthToken(token). 34 | Get(getSnykAPIURL() + "/rest/self" + SnykAPIVersion) 35 | 36 | if err != nil { 37 | log.Print(err) 38 | return "", err 39 | } 40 | 41 | if resp.StatusCode() == http.StatusOK { 42 | var userInfo selfDocument 43 | if err = json.Unmarshal(resp.Body(), &userInfo); err != nil { 44 | return "", fmt.Errorf("unable to retrieve org ID (status: %x): %w", resp.StatusCode(), err) 45 | } 46 | 47 | orgID = userInfo.Data.Attributes.DefaultOrgContext 48 | 49 | return orgID, nil 50 | } else { 51 | log.Println("Error: unexpected status code", resp.StatusCode()) 52 | return "", fmt.Errorf("unable to retrieve org ID (status: %x)", resp.StatusCode()) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /providers/snyk/orgid_test.go: -------------------------------------------------------------------------------- 1 | package snyk 2 | 3 | // func TestGetOrgID(t *testing.T) { 4 | // httpmock.Activate() 5 | // defer httpmock.DeactivateAndReset() 6 | 7 | // httpmock.RegisterResponder(http.MethodGet, `=~\/self`, httpmock.NewBytesResponder(200, selfResponse)) 8 | 9 | // orgID, err := getOrgID(os.Getenv("SNYK_TOKEN")) 10 | 11 | // assert.NoError(t, err) 12 | // assert.NotNil(t, orgID) 13 | // assert.Len(t, orgID, 36) 14 | // } 15 | 16 | // func TestGetOrgIDUnauthorized(t *testing.T) { 17 | // httpmock.Activate() 18 | // defer httpmock.DeactivateAndReset() 19 | 20 | // httpmock.RegisterResponder(http.MethodGet, `=~\/self`, httpmock.NewStringResponder(401, "Unauthorized")) 21 | 22 | // orgID, err := getOrgID("Yeah") 23 | 24 | // assert.Error(t, err) 25 | // assert.Equal(t, "unable to retrieve org ID (status: 401 Unauthorized)", err.Error()) 26 | // assert.Equal(t, "", orgID) 27 | // } 28 | -------------------------------------------------------------------------------- /providers/snyk/snyk.go: -------------------------------------------------------------------------------- 1 | // Package snyk contains functionality to retrieve vulnerability information from Snyk 2 | package snyk 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "log" 8 | "os" 9 | 10 | "github.com/remeh/sizedwaitgroup" 11 | 12 | "github.com/devops-kung-fu/bomber/models" 13 | ) 14 | 15 | const ( 16 | SnykURL = "https://api.snyk.io" 17 | SnykAPIVersion = "?version=2022-09-15~experimental" 18 | Concurrency = 10 19 | ) 20 | 21 | type Provider struct{} 22 | 23 | // Info provides basic information about the Snyk Provider 24 | func (Provider) Info() string { 25 | return "Snyk (https://security.snyk.io)" 26 | } 27 | 28 | func (Provider) SupportedEcosystems() []string { 29 | return []string{ 30 | "npm", 31 | "maven", 32 | "cocoapods", 33 | "composer", 34 | "rubygems", 35 | "nuget", 36 | "pypi", 37 | "hex", 38 | "cargo", 39 | "swift", 40 | "conan", 41 | "apk", 42 | "deb", 43 | "docker", 44 | "rpm", 45 | } 46 | } 47 | 48 | // Scan scans a list of Purls for vulnerabilities against Snyk. 49 | func (Provider) Scan(purls []string, credentials *models.Credentials) (packages []models.Package, err error) { 50 | if err = validateCredentials(credentials); err != nil { 51 | return packages, fmt.Errorf("could not validate credentials: %w", err) 52 | } 53 | wg := sizedwaitgroup.New(Concurrency) 54 | 55 | orgID, err := getOrgID(credentials.ProviderToken) 56 | if err != nil { 57 | return packages, fmt.Errorf("could not infer user’s Snyk organization: %w", err) 58 | } 59 | 60 | for _, pp := range purls { 61 | wg.Add() 62 | 63 | go func(purl string) { 64 | defer wg.Done() 65 | 66 | vulns, err := getVulnsForPurl(purl, orgID, credentials.ProviderToken) 67 | if err != nil { 68 | log.Printf("Could not get vulnerabilities for package (%s): %s\n", purl, err.Error()) 69 | } 70 | 71 | if len(vulns) == 0 { 72 | return 73 | } 74 | 75 | packages = append(packages, models.Package{ 76 | Purl: purl, 77 | Vulnerabilities: vulns, 78 | }) 79 | }(pp) 80 | } 81 | 82 | wg.Wait() 83 | return 84 | } 85 | 86 | func validateCredentials(credentials *models.Credentials) error { 87 | if credentials == nil { 88 | return errors.New("credentials cannot be nil") 89 | } 90 | 91 | if credentials.ProviderToken == "" { 92 | credentials.ProviderToken = os.Getenv("SNYK_TOKEN") 93 | } 94 | 95 | if credentials.ProviderToken == "" { 96 | credentials.ProviderToken = os.Getenv("BOMBER_PROVIDER_TOKEN") 97 | } 98 | 99 | if credentials.ProviderToken == "" { 100 | return errors.New("bomber requires a token to use the Snyk provider") 101 | } 102 | 103 | return nil 104 | } 105 | 106 | func getSnykAPIURL() string { 107 | u := os.Getenv("SNYK_API") 108 | if u != "" { 109 | return u 110 | } 111 | return SnykURL 112 | } 113 | -------------------------------------------------------------------------------- /providers/snyk/snyk_test.go: -------------------------------------------------------------------------------- 1 | package snyk 2 | 3 | import ( 4 | _ "embed" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/devops-kung-fu/bomber/models" 11 | ) 12 | 13 | // //go:embed testdata/snyk_package_issues_response.json 14 | // var issuesResponse []byte 15 | 16 | // //go:embed testdata/snyk_self_response.json 17 | // var selfResponse []byte 18 | 19 | // func TestInfo(t *testing.T) { 20 | // provider := Provider{} 21 | // info := provider.Info() 22 | // assert.Equal(t, "Snyk (https://security.snyk.io)", info) 23 | // } 24 | 25 | func Test_validateCredentials(t *testing.T) { 26 | // Back up any env tokens 27 | bomberToken := os.Getenv("BOMBER_PROVIDER_TOKEN") 28 | snykToken := os.Getenv("SNYK_TOKEN") 29 | 30 | os.Unsetenv("BOMBER_PROVIDER_TOKEN") 31 | os.Unsetenv("SNYK_TOKEN") 32 | 33 | credentials := models.Credentials{ 34 | ProviderToken: "token", 35 | } 36 | 37 | err := validateCredentials(nil) 38 | assert.Error(t, err) 39 | 40 | err = validateCredentials(&credentials) 41 | assert.NoError(t, err) 42 | 43 | credentials.ProviderToken = "" 44 | err = validateCredentials(&credentials) 45 | assert.Error(t, err) 46 | 47 | os.Setenv("BOMBER_PROVIDER_TOKEN", "bomber-token") 48 | 49 | err = validateCredentials(&credentials) 50 | assert.NoError(t, err) 51 | assert.Equal(t, "bomber-token", credentials.ProviderToken) 52 | 53 | os.Setenv("SNYK_TOKEN", "snyk-token") 54 | 55 | credentials.ProviderToken = "" 56 | err = validateCredentials(&credentials) 57 | assert.NoError(t, err) 58 | assert.Equal(t, "snyk-token", credentials.ProviderToken) 59 | 60 | //reset env 61 | os.Setenv("BOMBER_PROVIDER_TOKEN", bomberToken) 62 | os.Setenv("SNYK_TOKEN", snykToken) 63 | } 64 | 65 | func Test_getSnykAPIURL_default(t *testing.T) { 66 | assert.Equal(t, "https://api.snyk.io", getSnykAPIURL()) 67 | } 68 | 69 | func Test_getSnykAPIURL_override(t *testing.T) { 70 | os.Setenv("SNYK_API", "http://example.com") 71 | defer os.Unsetenv("SNYK_API") 72 | assert.Equal(t, "http://example.com", getSnykAPIURL()) 73 | } 74 | 75 | // func TestProvider_Scan_FakeCredentials(t *testing.T) { 76 | // httpmock.Activate() 77 | // defer httpmock.DeactivateAndReset() 78 | 79 | // httpmock.RegisterResponder("GET", `=~\/self`, httpmock.NewBytesResponder(200, selfResponse)) 80 | // httpmock.RegisterResponder("GET", `=~\/issues`, httpmock.NewBytesResponder(200, issuesResponse)) 81 | 82 | // credentials := models.Credentials{ 83 | // ProviderToken: "token", 84 | // } 85 | 86 | // provider := Provider{} 87 | // _, err := provider.Scan([]string{"pkg:gem/tzinfo@1.2.5"}, &credentials) 88 | // assert.Error(t, err) 89 | 90 | // } 91 | -------------------------------------------------------------------------------- /providers/snyk/testdata/snyk_package_issues_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "jsonapi": { 3 | "version": "1.0" 4 | }, 5 | "data": [ 6 | { 7 | "id": "SNYK-RUBY-TZINFO-2958048", 8 | "type": "issue", 9 | "attributes": { 10 | "key": "SNYK-RUBY-TZINFO-2958048", 11 | "title": "Directory Traversal", 12 | "type": "package_vulnerability", 13 | "created_at": "2022-07-22T07:23:05.273956Z", 14 | "updated_at": "2022-07-24T07:54:55.039170Z", 15 | "description": "", 16 | "problems": [ 17 | { 18 | "id": "CWE-22", 19 | "source": "CWE" 20 | }, 21 | { 22 | "id": "GHSA-5cm2-9h8c-rvfx", 23 | "source": "GHSA" 24 | }, 25 | { 26 | "id": "CVE-2022-31163", 27 | "source": "CVE" 28 | } 29 | ], 30 | "coordinates": [ 31 | { 32 | "remedies": [ 33 | { 34 | "type": "indeterminate", 35 | "description": "Upgrade the package version to 0.3.61,1.2.10 to fix this vulnerability", 36 | "details": { 37 | "upgrade_package": "0.3.61,1.2.10" 38 | } 39 | } 40 | ], 41 | "representation": ["<0.3.61", ">=1.0.0, <1.2.10"] 42 | } 43 | ], 44 | "severities": [ 45 | { 46 | "source": "Snyk", 47 | "level": "high", 48 | "score": 7.5, 49 | "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H" 50 | }, 51 | { 52 | "source": "SUSE", 53 | "level": "high", 54 | "score": 7.5, 55 | "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H" 56 | }, 57 | { 58 | "source": "NVD", 59 | "level": "high", 60 | "score": 8.1, 61 | "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H" 62 | } 63 | ], 64 | "effective_severity_level": "high", 65 | "slots": { 66 | "disclosure_time": "2022-07-21T21:39:29Z", 67 | "exploit": "Not Defined", 68 | "publication_time": "2022-07-22T07:23:05Z", 69 | "references": [ 70 | { 71 | "title": "GitHub 0.3.61 Release", 72 | "url": "https://github.com/tzinfo/tzinfo/releases/tag/v0.3.61" 73 | }, 74 | { 75 | "title": "GitHub 1.2.10 Release", 76 | "url": "https://github.com/tzinfo/tzinfo/releases/tag/v1.2.10" 77 | }, 78 | { 79 | "title": "GitHub Commit", 80 | "url": "https://github.com/tzinfo/tzinfo/commit/ca29f349856d62cb2b2edb3257d9ddd2f97b3c27" 81 | } 82 | ] 83 | } 84 | } 85 | } 86 | ], 87 | "links": { 88 | "self": "" 89 | }, 90 | "meta": { 91 | "package": { 92 | "name": "tzinfo", 93 | "type": "gem", 94 | "url": "pkg:gem/tzinfo@1.2.5", 95 | "version": "1.2.5" 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /providers/snyk/testdata/snyk_self_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "jsonapi": { 3 | "version": "1.0" 4 | }, 5 | "data": { 6 | "type": "user", 7 | "id": "3ecd1b48-9055-47fb-b959-a9fdbb3291dc", 8 | "attributes": { 9 | "name": "jane.doe@example.com", 10 | "avatar_url": "", 11 | "default_org_context": "d9546f87-d03d-4dd9-a10e-6ae5fd40d9a1", 12 | "username": "jane.doe@example.com", 13 | "email": "jane.doe@example.com" 14 | } 15 | }, 16 | "links": { 17 | "self": "" 18 | } 19 | } -------------------------------------------------------------------------------- /providers/snyk/vulns.go: -------------------------------------------------------------------------------- 1 | package snyk 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | "time" 12 | 13 | "github.com/go-resty/resty/v2" 14 | "github.com/package-url/packageurl-go" 15 | 16 | "github.com/devops-kung-fu/bomber/models" 17 | ) 18 | 19 | type SnykIssueResource struct { 20 | Attributes struct { 21 | Coordinates []Coordinate `json:"coordinates,omitempty"` 22 | CreatedAt time.Time `json:"created_at,omitempty"` 23 | 24 | // A description of the issue in Markdown format 25 | Description string `json:"description,omitempty"` 26 | 27 | // The type from enumeration of the issue’s severity level. This is usually set from the issue’s producer, but can be overridden by policies. 28 | EffectiveSeverityLevel EffectiveSeverityLevel `json:"effective_severity_level,omitempty"` 29 | 30 | // The Snyk vulnerability ID. 31 | Key string `json:"key,omitempty"` 32 | Problems []Problem `json:"problems,omitempty"` 33 | 34 | // The severity level of the vulnerability: ‘low’, ‘medium’, ‘high’ or ‘critical’. 35 | Severities []Severity `json:"severities,omitempty"` 36 | Slots Slots `json:"slots,omitempty"` 37 | 38 | // A human-readable title for this issue. 39 | Title string `json:"title,omitempty"` 40 | 41 | // The issue type 42 | Type string `json:"type,omitempty"` 43 | 44 | // When the vulnerability information was last modified. 45 | UpdatedAt *string `json:"updated_at,omitempty"` 46 | } `json:"attributes,omitempty"` 47 | 48 | // The Snyk ID of the vulnerability. 49 | ID string `json:"id,omitempty"` 50 | 51 | // The type of the REST resource. Always ‘issue’. 52 | Type string `json:"type,omitempty"` 53 | } 54 | 55 | type Coordinate struct { 56 | Remedies []Remedy `json:"remedies,omitempty"` 57 | 58 | // The affected versions of this vulnerability. 59 | Representation []string `json:"representation,omitempty"` 60 | } 61 | 62 | type IssuesMeta struct { 63 | Package PackageMeta `json:"package,omitempty"` 64 | } 65 | 66 | type Problem struct { 67 | // When this problem was disclosed to the public. 68 | DisclosedAt time.Time `json:"disclosed_at,omitempty"` 69 | 70 | // When this problem was first discovered. 71 | DiscoveredAt time.Time `json:"discovered_at,omitempty"` 72 | ID string `json:"id"` 73 | Source string `json:"source"` 74 | 75 | // When this problem was last updated. 76 | UpdatedAt time.Time `json:"updated_at,omitempty"` 77 | 78 | // An optional URL for this problem. 79 | URL *string `json:"url,omitempty"` 80 | } 81 | 82 | type Remedy struct { 83 | // A markdown-formatted optional description of this remedy. 84 | Description string `json:"description,omitempty"` 85 | Details struct { 86 | // A minimum version to upgrade to in order to remedy the issue. 87 | UpgradePackage string `json:"upgrade_package,omitempty"` 88 | } `json:"details,omitempty"` 89 | 90 | // The type of the remedy. Always ‘indeterminate’. 91 | Type string `json:"type,omitempty"` 92 | } 93 | 94 | type Severity struct { 95 | Level string `json:"level,omitempty"` 96 | 97 | // The CVSSv3 value of the vulnerability. 98 | Score float64 `json:"score,omitempty"` 99 | 100 | // The source of this severity. The value must be the id of a referenced problem or class, in which case that problem or class is the source of this issue. If source is omitted, this severity is sourced internally in the Snyk application. 101 | Source string `json:"source,omitempty"` 102 | 103 | // The CVSSv3 value of the vulnerability. 104 | Vector string `json:"vector,omitempty"` 105 | } 106 | 107 | type Slots struct { 108 | // The time at which this vulnerability was disclosed. 109 | DisclosureTime time.Time `json:"disclosure_time,omitempty"` 110 | 111 | // The exploit maturity. Value of ‘No Data’, ‘Not Defined’, ‘Unproven’, ‘Proof of Concept’, ‘Functional’ or ‘High’. 112 | Exploit string `json:"exploit,omitempty"` 113 | 114 | // The time at which this vulnerability was published. 115 | PublicationTime string `json:"publication_time,omitempty"` 116 | References []struct { 117 | // Descriptor for an external reference to the issue 118 | Title string `json:"title,omitempty"` 119 | 120 | // URL for an external reference to the issue 121 | URL string `json:"url,omitempty"` 122 | } `json:"references,omitempty"` 123 | } 124 | 125 | const ( 126 | Critical EffectiveSeverityLevel = "critical" 127 | High EffectiveSeverityLevel = "high" 128 | Info EffectiveSeverityLevel = "info" 129 | Low EffectiveSeverityLevel = "low" 130 | Medium EffectiveSeverityLevel = "medium" 131 | ) 132 | 133 | // EffectiveSeverityLevel The type from enumeration of the issue’s severity level. This is usually set from the issue’s producer, but can be overridden by policies. 134 | type EffectiveSeverityLevel string 135 | 136 | type SnykIssuesDocument struct { 137 | Data []SnykIssueResource `json:"data,omitempty"` 138 | Jsonapi JSONAPI `json:"jsonapi,omitempty"` 139 | Links *PaginatedLinks `json:"links,omitempty"` 140 | Meta *IssuesMeta `json:"meta,omitempty"` 141 | } 142 | 143 | func getVulnsForPurl( 144 | purl string, 145 | orgID string, 146 | token string, 147 | ) (vulns []models.Vulnerability, err error) { 148 | if err := validatePurl(purl); err != nil { 149 | return nil, err 150 | } 151 | 152 | issuesURL := fmt.Sprintf( 153 | "%s/rest/orgs/%s/packages/%s/issues%s", 154 | getSnykAPIURL(), orgID, url.QueryEscape(purl), SnykAPIVersion, 155 | ) 156 | 157 | client := resty.New() 158 | client.Debug = true 159 | 160 | resp, err := client.R(). 161 | SetHeader("User-Agent", "bomber"). 162 | SetAuthToken(token). 163 | Get(issuesURL) 164 | 165 | if err != nil { 166 | log.Print(err) 167 | return nil, err 168 | } 169 | 170 | if resp.StatusCode() == http.StatusOK { 171 | var response SnykIssuesDocument 172 | if err = json.Unmarshal(resp.Body(), &response); err != nil { 173 | log.Println("Error:", err) 174 | return nil, err 175 | } 176 | 177 | for _, v := range response.Data { 178 | vuln := snykIssueToBomberVuln(v) 179 | vulns = append(vulns, vuln) 180 | } 181 | 182 | return vulns, nil 183 | } else { 184 | log.Println("Error: unexpected status code", resp.StatusCode()) 185 | return nil, errors.New("unexpected status code") 186 | } 187 | } 188 | 189 | func validatePurl(purl string) error { 190 | if _, err := packageurl.FromString(purl); err != nil { 191 | return fmt.Errorf("invalid purl: %w", err) 192 | } 193 | return nil 194 | } 195 | 196 | func snykIssueToBomberVuln(v SnykIssueResource) models.Vulnerability { 197 | cvss := getCvss(v) 198 | severity := strings.ToUpper(string(v.Attributes.EffectiveSeverityLevel)) 199 | 200 | if severity == "MEDIUM" { 201 | severity = "MODERATE" 202 | } 203 | 204 | return models.Vulnerability{ 205 | ID: v.ID, 206 | Title: v.Attributes.Title, 207 | Description: v.Attributes.Description, 208 | Severity: severity, 209 | Cwe: getCwe(v), 210 | Cve: getCve(v), 211 | CvssScore: float64(cvss.Score), 212 | CvssVector: cvss.Vector, 213 | Reference: fmt.Sprintf("https://security.snyk.io/vuln/%s", v.ID), 214 | ExternalReferences: getExternalReferences(v), 215 | } 216 | } 217 | 218 | func getCwe(i SnykIssueResource) string { 219 | for _, p := range i.Attributes.Problems { 220 | if p.Source == "CWE" { 221 | return p.ID 222 | } 223 | } 224 | return "" 225 | } 226 | 227 | func getCve(i SnykIssueResource) string { 228 | for _, p := range i.Attributes.Problems { 229 | if p.Source == "CVE" { 230 | return p.ID 231 | } 232 | } 233 | return "" 234 | } 235 | 236 | func getCvss(i SnykIssueResource) *Severity { 237 | var nvdSeverity *Severity 238 | for _, ss := range i.Attributes.Severities { 239 | switch ss.Source { 240 | case "Snyk": 241 | return &ss 242 | case "NVD": 243 | nvdSeverity = &ss 244 | } 245 | } 246 | if nvdSeverity != nil { 247 | return nvdSeverity 248 | } 249 | if len(i.Attributes.Severities) > 0 { 250 | return &i.Attributes.Severities[0] 251 | } 252 | return &Severity{} 253 | } 254 | 255 | func getExternalReferences(i SnykIssueResource) (refs []interface{}) { 256 | for _, r := range i.Attributes.Slots.References { 257 | refs = append(refs, r.URL) 258 | } 259 | return refs 260 | } 261 | -------------------------------------------------------------------------------- /providers/snyk/vulns_test.go: -------------------------------------------------------------------------------- 1 | package snyk 2 | 3 | // const orgID string = "33e75b5e-3ebe-4d2e-8eba-17a24d20fc72" 4 | 5 | // func TestGetVulnsForPurlSuccess(t *testing.T) { 6 | // httpmock.Activate() 7 | // defer httpmock.DeactivateAndReset() 8 | 9 | // httpmock.RegisterResponder("GET", `=~\/issues\?version=`, httpmock.NewBytesResponder(200, issuesResponse)) 10 | 11 | // expected := []models.Vulnerability{ 12 | // { 13 | // ID: "SNYK-RUBY-TZINFO-2958048", 14 | // Title: "Directory Traversal", 15 | // Severity: "HIGH", 16 | // CvssScore: float64(7.5), 17 | // CvssVector: "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H", 18 | // Cwe: "CWE-22", 19 | // Cve: "CVE-2022-31163", 20 | // Reference: "https://security.snyk.io/vuln/SNYK-RUBY-TZINFO-2958048", 21 | // ExternalReferences: []interface{}{ 22 | // "https://github.com/tzinfo/tzinfo/releases/tag/v0.3.61", 23 | // "https://github.com/tzinfo/tzinfo/releases/tag/v1.2.10", 24 | // "https://github.com/tzinfo/tzinfo/commit/ca29f349856d62cb2b2edb3257d9ddd2f97b3c27", 25 | // }, 26 | // }, 27 | // } 28 | 29 | // vulns, err := getVulnsForPurl("pkg:gem/tzinfo@1.2.5", orgID, os.Getenv("SNYK_TOKEN")) 30 | 31 | // assert.NoError(t, err) 32 | // assert.Equal(t, expected, vulns) 33 | // } 34 | 35 | // func TestGetVulnsForPurlTimeout(t *testing.T) { 36 | // httpmock.Activate() 37 | // defer httpmock.DeactivateAndReset() 38 | 39 | // httpmock.RegisterResponder("GET", `=~\/issues\?version=`, httpmock.NewStringResponder(503, "Gateway Timeout")) 40 | 41 | // vulns, err := getVulnsForPurl("pkg:gem/tzinfo@1.2.5", orgID, os.Getenv("SNYK_TOKEN")) 42 | 43 | // assert.Error(t, err) 44 | // assert.Equal(t, "failed request while retrieving vulnerabilities (purl: pkg:gem/tzinfo@1.2.5, status: 503)", err.Error()) 45 | // assert.Nil(t, vulns) 46 | // } 47 | 48 | // func TestGetVulnsForPurlInvalidResponse(t *testing.T) { 49 | // httpmock.Activate() 50 | // defer httpmock.DeactivateAndReset() 51 | 52 | // httpmock.RegisterResponder("GET", `=~\/issues\?version=`, httpmock.NewStringResponder(200, "BOOM")) 53 | 54 | // vulns, err := getVulnsForPurl("pkg:gem/tzinfo@1.2.5", orgID, os.Getenv("SNYK_TOKEN")) 55 | 56 | // assert.Error(t, err) 57 | // assert.Equal(t, "could not parse response (purl: pkg:gem/tzinfo@1.2.5): invalid character 'B' looking for beginning of value", err.Error()) 58 | // assert.Nil(t, vulns) 59 | // } 60 | 61 | // func TestGetVulnsForPurlInvalidPurl(t *testing.T) { 62 | // httpmock.Activate() 63 | // defer httpmock.DeactivateAndReset() 64 | 65 | // vulns, err := getVulnsForPurl("foobar", orgID, os.Getenv("SNYK_TOKEN")) 66 | 67 | // assert.Error(t, err) 68 | // assert.Nil(t, vulns) 69 | // } 70 | 71 | // func TestSnykIssueToBomberVuln(t *testing.T) { 72 | // issue, err := snykIssueMock() 73 | // assert.NoError(t, err) 74 | // vuln := snykIssueToBomberVuln(issue) 75 | // expected := models.Vulnerability{ 76 | // ID: "SNYK-RUBY-TZINFO-2958048", 77 | // Title: "Directory Traversal", 78 | // Description: "", 79 | // Severity: "HIGH", 80 | // Cwe: "CWE-22", 81 | // Cve: "CVE-2022-31163", 82 | // CvssScore: 7.5, 83 | // CvssVector: "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H", 84 | // Reference: "https://security.snyk.io/vuln/SNYK-RUBY-TZINFO-2958048", 85 | // ExternalReferences: []interface{}{ 86 | // "https://github.com/tzinfo/tzinfo/releases/tag/v0.3.61", 87 | // "https://github.com/tzinfo/tzinfo/releases/tag/v1.2.10", 88 | // "https://github.com/tzinfo/tzinfo/commit/ca29f349856d62cb2b2edb3257d9ddd2f97b3c27", 89 | // }, 90 | // } 91 | 92 | // assert.Equal(t, expected, vuln) 93 | // } 94 | 95 | // func TestSnykIssueToBomberVulnModerate(t *testing.T) { 96 | // issue, err := snykIssueMock() 97 | // assert.NoError(t, err) 98 | // issue.Attributes.EffectiveSeverityLevel = "medium" 99 | 100 | // vuln := snykIssueToBomberVuln(issue) 101 | 102 | // assert.Equal(t, "MODERATE", vuln.Severity) 103 | // } 104 | 105 | // func TestSnykIssueToBomberVulnMissingCwe(t *testing.T) { 106 | // issue, err := snykIssueMock() 107 | // assert.NoError(t, err) 108 | // issue.Attributes.Problems = []Problem{} 109 | 110 | // vuln := snykIssueToBomberVuln(issue) 111 | 112 | // assert.Equal(t, "", vuln.Cwe) 113 | // } 114 | 115 | // func TestSnykIssueToBomberVulnSnykSeverity(t *testing.T) { 116 | // tc := []struct { 117 | // Title string 118 | // Severities []Severity 119 | // ExpectedCvssScore float64 120 | // }{ 121 | // { 122 | // "prefer Snyk score", 123 | // []Severity{ 124 | // {Source: "SUSE", Score: 7}, 125 | // {Source: "Snyk", Score: 8}, 126 | // {Source: "NVD", Score: 9}, 127 | // }, 128 | // float64(8), 129 | // }, 130 | // { 131 | // "prefer NVD score", 132 | // []Severity{ 133 | // {Source: "SUSE", Score: 7}, 134 | // {Source: "NVD", Score: 9}, 135 | // }, 136 | // float64(9), 137 | // }, 138 | // { 139 | // "fallback on other", 140 | // []Severity{ 141 | // {Source: "SUSE", Score: 7}, 142 | // }, 143 | // float64(7), 144 | // }, 145 | // { 146 | // "empty severities", // edge case, should not happen 147 | // []Severity{}, 148 | // float64(0), 149 | // }, 150 | // } 151 | // issue, err := snykIssueMock() 152 | // assert.NoError(t, err) 153 | 154 | // for _, tt := range tc { 155 | // t.Run(tt.Title, func(t *testing.T) { 156 | // issue.Attributes.Severities = tt.Severities 157 | // vuln := snykIssueToBomberVuln(issue) 158 | // assert.Equal(t, tt.ExpectedCvssScore, vuln.CvssScore) 159 | // }) 160 | // } 161 | // } 162 | 163 | // func TestSnykIssueToBomberVulnOtherSeverity(t *testing.T) { 164 | // issue, err := snykIssueMock() 165 | // assert.NoError(t, err) 166 | // issue.Attributes.Severities = []Severity{ 167 | // {Source: "SUSE", Score: 7}, 168 | // } 169 | 170 | // vuln := snykIssueToBomberVuln(issue) 171 | 172 | // assert.Equal(t, float64(7), vuln.CvssScore) 173 | // } 174 | 175 | // func TestValidatePurl(t *testing.T) { 176 | // t.Run("should raise error for invalid purl", func(t *testing.T) { 177 | // err := validatePurl("foobar") 178 | // assert.Error(t, err) 179 | // }) 180 | 181 | // t.Run("should not raise error for valid purl", func(t *testing.T) { 182 | // err := validatePurl("pkg:gem/tzinfo@1.2.5") 183 | 184 | // assert.NoError(t, err) 185 | // }) 186 | // } 187 | 188 | // func snykIssueMock() (issue SnykIssueResource, err error) { 189 | // var doc SnykIssuesDocument 190 | // if err := json.Unmarshal(issuesResponse, &doc); err != nil { 191 | // return issue, err 192 | // } 193 | // return doc.Data[0], nil 194 | // } 195 | -------------------------------------------------------------------------------- /renderers/ai/ai_test.go: -------------------------------------------------------------------------------- 1 | // Package ai contains functionality to render output using GenAI 2 | package ai 3 | 4 | import ( 5 | "testing" 6 | 7 | "github.com/devops-kung-fu/common/util" 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/devops-kung-fu/bomber/models" 11 | ) 12 | 13 | func TestRenderer_Render(t *testing.T) { 14 | output := util.CaptureOutput(func() { 15 | renderer := Renderer{} 16 | _ = renderer.Render(models.NewResults([]models.Package{}, models.Summary{}, []models.ScannedFile{}, []string{"GPL"}, "0.0.0", "test", "")) 17 | }) 18 | assert.NotNil(t, output) 19 | } 20 | -------------------------------------------------------------------------------- /renderers/html/html_test.go: -------------------------------------------------------------------------------- 1 | package html 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/devops-kung-fu/common/util" 9 | "github.com/spf13/afero" 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/devops-kung-fu/bomber/models" 13 | ) 14 | 15 | func Test_writeTemplate(t *testing.T) { 16 | afs := &afero.Afero{Fs: afero.NewMemMapFs()} 17 | 18 | err := writeTemplate(afs, "test.html", models.NewResults([]models.Package{}, models.Summary{}, []models.ScannedFile{}, []string{"GPL"}, "0.0.0", "test", "low")) 19 | assert.NoError(t, err) 20 | 21 | b, err := afs.ReadFile("test.html") 22 | assert.NotNil(t, b) 23 | assert.NoError(t, err) 24 | 25 | info, err := afs.Stat("test.html") 26 | assert.NoError(t, err) 27 | assert.Equal(t, os.FileMode(0644), info.Mode().Perm()) 28 | } 29 | 30 | func Test_genTemplate(t *testing.T) { 31 | template := genTemplate("test") 32 | 33 | assert.NotNil(t, template) 34 | assert.Len(t, template.Tree.Root.Nodes, 17) 35 | } 36 | 37 | func TestRenderer_Render(t *testing.T) { 38 | output := util.CaptureOutput(func() { 39 | renderer := Renderer{} 40 | err := renderer.Render(models.NewResults([]models.Package{}, models.Summary{}, []models.ScannedFile{}, []string{"GPL"}, "0.0.0", "test", "")) 41 | if err != nil { 42 | fmt.Println(err) 43 | } 44 | }) 45 | assert.NotNil(t, output) 46 | } 47 | 48 | func Test_processPercentiles(t *testing.T) { 49 | // Create a sample Results struct for testing 50 | results := models.Results{ 51 | Packages: []models.Package{ 52 | { 53 | Vulnerabilities: []models.Vulnerability{ 54 | { 55 | Epss: models.EpssScore{Percentile: "0.75"}, 56 | }, 57 | { 58 | Epss: models.EpssScore{Percentile: "invalid"}, // Simulate an invalid percentile 59 | }, 60 | { 61 | Epss: models.EpssScore{Percentile: "0"}, // Simulate a zero percentile 62 | }, 63 | }, 64 | }, 65 | }, 66 | } 67 | 68 | processPercentiles(results) 69 | 70 | assert.Equal(t, "75%", results.Packages[0].Vulnerabilities[0].Epss.Percentile, "Expected 75% percentile") 71 | assert.Equal(t, "invalid", results.Packages[0].Vulnerabilities[1].Epss.Percentile, "Expected invalid for invalid percentile") 72 | assert.Equal(t, "N/A", results.Packages[0].Vulnerabilities[2].Epss.Percentile, "Expected N/A for zero percentile") 73 | } 74 | 75 | -------------------------------------------------------------------------------- /renderers/json/json.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/devops-kung-fu/bomber/models" 9 | ) 10 | 11 | // Renderer contains methods to render to JSON format 12 | type Renderer struct{} 13 | 14 | // Render outputs json to STDOUT 15 | func (Renderer) Render(results models.Results) error { 16 | b, err := json.MarshalIndent(results, "", "\t") 17 | if err != nil { 18 | log.Println(err) 19 | return err 20 | } 21 | 22 | fmt.Println(string(b)) 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /renderers/json/json_test.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/devops-kung-fu/common/util" 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/devops-kung-fu/bomber/models" 10 | ) 11 | 12 | func TestRenderer_Render(t *testing.T) { 13 | output := util.CaptureOutput(func() { 14 | renderer := Renderer{} 15 | renderer.Render(models.NewResults([]models.Package{}, models.Summary{}, []models.ScannedFile{}, []string{"GPL"}, "0.0.0", "test", "")) 16 | }) 17 | assert.NotNil(t, output) 18 | assert.Contains(t, output, "generator\": \"bomber\"") 19 | } 20 | -------------------------------------------------------------------------------- /renderers/jsonfile/jsonfile.go: -------------------------------------------------------------------------------- 1 | // Package json contains functionality to render output in json format 2 | package jsonfile 3 | 4 | import ( 5 | "encoding/json" 6 | "log" 7 | "os" 8 | 9 | "github.com/devops-kung-fu/common/util" 10 | 11 | "github.com/devops-kung-fu/bomber/lib" 12 | "github.com/devops-kung-fu/bomber/models" 13 | ) 14 | 15 | // Renderer contains methods to render to JSON format 16 | type Renderer struct{} 17 | 18 | // Render outputs json to STDOUT 19 | func (Renderer) Render(results models.Results) error { 20 | b, _ := json.MarshalIndent(results, "", "\t") 21 | filename := lib.GenerateFilename("json") 22 | util.PrintInfo("Writing JSON output:", filename) 23 | if err := os.WriteFile(filename, b, 0666); err != nil { 24 | log.Fatal(err) 25 | } 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /renderers/md/md.go: -------------------------------------------------------------------------------- 1 | package md 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math" 7 | "path/filepath" 8 | "strconv" 9 | "strings" 10 | "text/template" 11 | "time" 12 | 13 | "github.com/devops-kung-fu/common/util" 14 | "github.com/spf13/afero" 15 | 16 | "github.com/devops-kung-fu/bomber/models" 17 | ) 18 | 19 | // Renderer contains methods to render results to a Markdown file 20 | type Renderer struct{} 21 | 22 | // Render renders results to a Markdown file 23 | func (Renderer) Render(results models.Results) error { 24 | var afs *afero.Afero 25 | 26 | if results.Meta.Provider == "test" { 27 | afs = &afero.Afero{Fs: afero.NewMemMapFs()} 28 | } else { 29 | afs = &afero.Afero{Fs: afero.NewOsFs()} 30 | } 31 | 32 | filename := generateFilename() 33 | util.PrintInfo("Writing filename:", filename) 34 | 35 | err := writeTemplate(afs, filename, results) 36 | 37 | return err 38 | } 39 | 40 | // generateFilename generates a unique filename based on the current timestamp 41 | // in the format "2006-01-02 15:04:05" and replaces certain characters to 42 | // create a valid filename. The resulting filename is a combination of the 43 | // timestamp and a fixed suffix. 44 | func generateFilename() string { 45 | t := time.Now() 46 | r := strings.NewReplacer("-", "", " ", "-", ":", "-") 47 | return filepath.Join(".", fmt.Sprintf("%s-bomber-results.md", r.Replace(t.Format("2006-01-02 15:04:05")))) 48 | } 49 | 50 | // writeTemplate writes the results to a file with the specified filename, 51 | // using the given Afero filesystem interface. It creates the file, processes 52 | // percentiles in the results and writes the templated results to the file. 53 | // It also sets file permissions to 0777. 54 | func writeTemplate(afs *afero.Afero, filename string, results models.Results) error { 55 | processPercentiles(results) 56 | 57 | file, err := afs.Create(filename) 58 | if err != nil { 59 | log.Println(err) 60 | return err 61 | } 62 | 63 | template := genTemplate("output") 64 | err = template.ExecuteTemplate(file, "output", results) 65 | if err != nil { 66 | log.Println(err) 67 | return err 68 | } 69 | 70 | err = afs.Fs.Chmod(filename, 0777) 71 | 72 | return err 73 | } 74 | 75 | // processPercentiles calculates and updates the percentile values for 76 | // vulnerabilities in the given results. It converts the percentile from 77 | // a decimal to a percentage and updates the results in place. 78 | func processPercentiles(results models.Results) { 79 | for i, p := range results.Packages { 80 | for vi, v := range p.Vulnerabilities { 81 | per, err := strconv.ParseFloat(v.Epss.Percentile, 64) 82 | if err != nil { 83 | log.Println(err) 84 | } else { 85 | percentage := math.Round(per * 100) 86 | if percentage > 0 { 87 | results.Packages[i].Vulnerabilities[vi].Epss.Percentile = fmt.Sprintf("%d%%", uint64(percentage)) 88 | } else { 89 | results.Packages[i].Vulnerabilities[vi].Epss.Percentile = "N/A" 90 | } 91 | } 92 | } 93 | } 94 | } 95 | 96 | func genTemplate(output string) (t *template.Template) { 97 | 98 | content := ` 99 | ![IMG](https://raw.githubusercontent.com/devops-kung-fu/bomber/main/img/bomber-readme-logo.png) 100 | 101 | The following results were detected by `+ "`{{.Meta.Generator}} {{.Meta.Version}}`" + ` on {{.Meta.Date}} using the {{.Meta.Provider}} provider. 102 | {{ if ne (len .Packages) 0 }} 103 | 104 | Vulnerabilities displayed may differ from provider to provider. This list may not contain all possible vulnerabilities. Please try the other providers that ` + "`bomber`" + ` supports (osv, github, ossindex, snyk). There is no guarantee that the next time you scan for vulnerabilities that there won't be more, or less of them. Threats are continuous. 105 | 106 | EPSS Percentage indicates the % chance that the vulnerability will be exploited. This value will assist in prioritizing remediation. For more information on EPSS, refer to [https://www.first.org/epss/](https://www.first.org/epss/) 107 | {{ else }} 108 | No vulnerabilities found! 109 | {{ end }} 110 | 111 | {{ if ne (len .Files) 0 }} 112 | ## Scanned Files 113 | 114 | {{ range .Files }}**{{ .Name }}** (sha256:{{ .SHA256 }}){{ end }} 115 | {{end}} 116 | {{ if ne (len .Licenses) 0 }} 117 | ## Licenses 118 | 119 | The following licenses were found by ` + "`bomber`" + `: 120 | {{ range $license := .Licenses }} 121 | - {{ $license }}{{ end }} 122 | {{ else }} 123 | **No license information detected.** 124 | {{ end }} 125 | 126 | {{ if ne (len .Packages) 0 }} 127 | ## Vulnerability Summary 128 | 129 | {{ if ne (len .Meta.SeverityFilter) 0 }} 130 | Only showing vulnerabilities with a severity of ***{{ .Meta.SeverityFilter }}*** or higher. 131 | 132 | {{ end }} 133 | | Severity | Count | 134 | | --- | --- |{{ if gt .Summary.Critical 0 }} 135 | | Critical | {{ .Summary.Critical }} |{{ end }}{{ if gt .Summary.High 0 }} 136 | | High | {{ .Summary.High }} |{{ end }}{{ if gt .Summary.Moderate 0 }} 137 | | Moderate | {{ .Summary.Moderate }} |{{ end }}{{ if gt .Summary.Low 0 }} 138 | | Low | {{ .Summary.Low }} |{{ end }}{{ if gt .Summary.Unspecified 0 }} 139 | | Unspecified | {{ .Summary.Unspecified }} |{{ end }} 140 | 141 | ## Vulnerability Details 142 | 143 | {{ range .Packages }} 144 | ### {{ .Purl }} 145 | {{if .Description }}{{ .Description }}{{ end }} 146 | #### Vulnerabilities 147 | 148 | {{ range .Vulnerabilities }} 149 | {{ if .Title }}Title: **{{ .Title }}**
{{ end }} 150 | Severity: **{{ .Severity }}**
151 | {{ if ne (len .Epss.Percentile) 0 }} EPSS: {{ .Epss.Percentile }}
{{ end }} 152 | [Reference Documentation]({{ .Reference }}) 153 | 154 | {{ .Description }} 155 | 156 |
157 | 158 | {{ end }} 159 | 160 | {{ end }} 161 | {{ end }} 162 | 163 | Powered by the [DevOps Kung Fu Mafia](https://github.com/devops-kung-fu) 164 | ` 165 | return template.Must(template.New(output).Parse(content)) 166 | } 167 | -------------------------------------------------------------------------------- /renderers/md/md_test.go: -------------------------------------------------------------------------------- 1 | package md 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/devops-kung-fu/common/util" 9 | "github.com/spf13/afero" 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/devops-kung-fu/bomber/models" 13 | ) 14 | 15 | func Test_writeTemplate(t *testing.T) { 16 | afs := &afero.Afero{Fs: afero.NewMemMapFs()} 17 | 18 | err := writeTemplate(afs, "test.md", models.NewResults([]models.Package{}, models.Summary{}, []models.ScannedFile{}, []string{"GPL"}, "0.0.0", "test", "low")) 19 | assert.NoError(t, err) 20 | 21 | b, err := afs.ReadFile("test.md") 22 | assert.NotNil(t, b) 23 | assert.NoError(t, err) 24 | 25 | info, err := afs.Stat("test.md") 26 | assert.NoError(t, err) 27 | assert.Equal(t, os.FileMode(0777), info.Mode().Perm()) 28 | } 29 | 30 | func Test_genTemplate(t *testing.T) { 31 | template := genTemplate("test") 32 | 33 | assert.NotNil(t, template) 34 | assert.Len(t, template.Root.Nodes, 17) 35 | } 36 | 37 | func TestRenderer_Render(t *testing.T) { 38 | output := util.CaptureOutput(func() { 39 | renderer := Renderer{} 40 | err := renderer.Render(models.NewResults([]models.Package{}, models.Summary{}, []models.ScannedFile{}, []string{"GPL"}, "0.0.0", "test", "")) 41 | if err != nil { 42 | fmt.Println(err) 43 | } 44 | }) 45 | assert.NotNil(t, output) 46 | } 47 | 48 | func Test_processPercentiles(t *testing.T) { 49 | // Create a sample Results struct for testing 50 | results := models.Results{ 51 | Packages: []models.Package{ 52 | { 53 | Vulnerabilities: []models.Vulnerability{ 54 | { 55 | Epss: models.EpssScore{Percentile: "0.75"}, 56 | }, 57 | { 58 | Epss: models.EpssScore{Percentile: "invalid"}, // Simulate an invalid percentile 59 | }, 60 | { 61 | Epss: models.EpssScore{Percentile: "0"}, // Simulate a zero percentile 62 | }, 63 | }, 64 | }, 65 | }, 66 | } 67 | 68 | processPercentiles(results) 69 | 70 | assert.Equal(t, "75%", results.Packages[0].Vulnerabilities[0].Epss.Percentile, "Expected 75% percentile") 71 | assert.Equal(t, "invalid", results.Packages[0].Vulnerabilities[1].Epss.Percentile, "Expected invalid for invalid percentile") 72 | assert.Equal(t, "N/A", results.Packages[0].Vulnerabilities[2].Epss.Percentile, "Expected N/A for zero percentile") 73 | } 74 | -------------------------------------------------------------------------------- /renderers/rendererfactory.go: -------------------------------------------------------------------------------- 1 | // Package renderers contains functionality to render output in various formats 2 | package renderers 3 | 4 | import ( 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/devops-kung-fu/bomber/models" 9 | "github.com/devops-kung-fu/bomber/renderers/ai" 10 | "github.com/devops-kung-fu/bomber/renderers/html" 11 | "github.com/devops-kung-fu/bomber/renderers/json" 12 | "github.com/devops-kung-fu/bomber/renderers/jsonfile" 13 | "github.com/devops-kung-fu/bomber/renderers/md" 14 | "github.com/devops-kung-fu/bomber/renderers/stdout" 15 | ) 16 | 17 | // NewRenderer will return a Renderer interface for the requested output 18 | func NewRenderer(output string) (renderers []models.Renderer, err error) { 19 | for _, s := range strings.Split(output, ",") { 20 | switch s { 21 | case "stdout": 22 | renderers = append(renderers, stdout.Renderer{}) 23 | case "json": 24 | renderers = append(renderers, json.Renderer{}) 25 | case "json-file": 26 | renderers = append(renderers, jsonfile.Renderer{}) 27 | case "html": 28 | renderers = append(renderers, html.Renderer{}) 29 | case "ai": 30 | renderers = append(renderers, ai.Renderer{}) 31 | case "md": 32 | renderers = append(renderers, md.Renderer{}) 33 | default: 34 | err = fmt.Errorf("%s is not a valid output type", s) 35 | } 36 | } 37 | return 38 | } 39 | -------------------------------------------------------------------------------- /renderers/rendererfactory_test.go: -------------------------------------------------------------------------------- 1 | package renderers 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/devops-kung-fu/bomber/renderers/ai" 9 | "github.com/devops-kung-fu/bomber/renderers/html" 10 | "github.com/devops-kung-fu/bomber/renderers/json" 11 | "github.com/devops-kung-fu/bomber/renderers/jsonfile" 12 | "github.com/devops-kung-fu/bomber/renderers/md" 13 | "github.com/devops-kung-fu/bomber/renderers/stdout" 14 | ) 15 | 16 | func TestNewRenderer(t *testing.T) { 17 | renderers, err := NewRenderer("stdout") 18 | assert.NoError(t, err) 19 | assert.IsType(t, stdout.Renderer{}, renderers[0]) 20 | 21 | renderers, err = NewRenderer("json") 22 | assert.NoError(t, err) 23 | assert.IsType(t, json.Renderer{}, renderers[0]) 24 | 25 | renderers, err = NewRenderer("html") 26 | assert.NoError(t, err) 27 | assert.IsType(t, html.Renderer{}, renderers[0]) 28 | 29 | renderers, err = NewRenderer("ai") 30 | assert.NoError(t, err) 31 | assert.IsType(t, ai.Renderer{}, renderers[0]) 32 | 33 | renderers, err = NewRenderer("json-file") 34 | assert.NoError(t, err) 35 | assert.IsType(t, jsonfile.Renderer{}, renderers[0]) 36 | 37 | renderers, err = NewRenderer("stdout,json-file,html,json") 38 | assert.NoError(t, err) 39 | assert.IsType(t, stdout.Renderer{}, renderers[0]) 40 | assert.IsType(t, jsonfile.Renderer{}, renderers[1]) 41 | assert.IsType(t, html.Renderer{}, renderers[2]) 42 | assert.IsType(t, json.Renderer{}, renderers[3]) 43 | assert.Len(t, renderers, 4) 44 | 45 | renderers, err = NewRenderer("md") 46 | assert.NoError(t, err) 47 | assert.IsType(t, md.Renderer{}, renderers[0]) 48 | 49 | _, err = NewRenderer("test") 50 | assert.Error(t, err) 51 | } 52 | -------------------------------------------------------------------------------- /renderers/stdout/stdout.go: -------------------------------------------------------------------------------- 1 | // Package stdout contains functionality to render output to a command line 2 | package stdout 3 | 4 | import ( 5 | "fmt" 6 | "log" 7 | "math" 8 | "os" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/devops-kung-fu/common/util" 13 | "github.com/gookit/color" 14 | "github.com/jedib0t/go-pretty/v6/table" 15 | "github.com/package-url/packageurl-go" 16 | 17 | "github.com/devops-kung-fu/bomber/models" 18 | ) 19 | 20 | // Renderer contains methods to render a pretty tabular summary to STDOUT 21 | type Renderer struct{} 22 | 23 | // Render renders a pretty tabular summary to STDOUT 24 | func (Renderer) Render(results models.Results) (err error) { 25 | if len(results.Files) > 0 { 26 | util.PrintInfo("Files Scanned") 27 | for _, scanned := range results.Files { 28 | util.PrintTabbedf("%s (sha256:%s)\n", scanned.Name, scanned.SHA256) 29 | } 30 | fmt.Println() 31 | 32 | } 33 | if len(results.Licenses) > 0 { 34 | util.PrintInfo("Licenses Found:", strings.Join(results.Licenses[:], ", ")) 35 | fmt.Println() 36 | } 37 | vulnCount := vulnerabilityCount(results.Packages) 38 | if len(results.Packages) != 0 { 39 | log.Println("Rendering Packages:", len(results.Packages)) 40 | t := table.NewWriter() 41 | t.SetOutputMirror(os.Stdout) 42 | rowConfigAutoMerge := table.RowConfig{AutoMerge: true} 43 | t.AppendHeader(table.Row{"Type", "Name", "Version", "Severity", "Vulnerability", "EPSS %"}, rowConfigAutoMerge) 44 | for _, r := range results.Packages { 45 | purl, _ := packageurl.FromString(r.Purl) 46 | for _, v := range r.Vulnerabilities { 47 | p, _ := strconv.ParseFloat(v.Epss.Percentile, 64) 48 | percentage := math.Round(p * 100) 49 | percentageString := "N/A" 50 | if percentage > 0 { 51 | percentageString = fmt.Sprintf("%d%%", uint64(percentage)) 52 | } 53 | t.AppendRow([]interface{}{purl.Type, purl.Name, purl.Version, v.Severity, v.ID, percentageString}, rowConfigAutoMerge) 54 | } 55 | } 56 | t.SetStyle(table.StyleRounded) 57 | t.SetColumnConfigs([]table.ColumnConfig{ 58 | { 59 | Name: "Description", 60 | WidthMin: 6, 61 | WidthMax: 64, 62 | }, 63 | }) 64 | t.SortBy([]table.SortBy{ 65 | {Name: "Name", Mode: table.Dsc}, 66 | {Name: "Severity", Mode: table.Dsc}, 67 | }) 68 | t.SetColumnConfigs([]table.ColumnConfig{ 69 | {Number: 1, AutoMerge: true}, 70 | {Number: 2, AutoMerge: true}, 71 | }) 72 | t.Style().Options.SeparateRows = true 73 | t.Render() 74 | } 75 | renderFooter(vulnCount, results) 76 | return 77 | } 78 | 79 | func renderFooter(vulnCount int, results models.Results) { 80 | if vulnCount > 0 { 81 | fmt.Println() 82 | color.Red.Printf("Total vulnerabilities found: %v\n", vulnCount) 83 | fmt.Println() 84 | renderSeveritySummary(results.Summary) 85 | fmt.Println() 86 | if results.Meta.SeverityFilter != "" { 87 | util.PrintWarningf("Only displaying vulnerabilities with a severity of %s or higher", strings.ToUpper(results.Meta.SeverityFilter)) 88 | fmt.Println() 89 | } 90 | fmt.Println() 91 | fmt.Println("NOTES:") 92 | fmt.Println() 93 | fmt.Println("1. The list of vulnerabilities displayed may differ from provider to provider. This list") 94 | fmt.Println(" may not contain all possible vulnerabilities. Please try the other providers that bomber") 95 | fmt.Println(" supports (osv, ossindex, snyk)") 96 | fmt.Println("2. EPSS Percentage indicates the % chance that the vulnerability will be exploited. This") 97 | fmt.Println(" value will assist in prioritizing remediation. For more information on EPSS, refer to") 98 | fmt.Println(" https://www.first.org/epss/") 99 | fmt.Println("3. An EPSS Percentage showing as N/A means that no EPSS data was available for the vulnerability") 100 | fmt.Println(" or the --enrich=epss flag was not set when running bomber") 101 | } else { 102 | color.Green.Printf("No vulnerabilities found using the %v provider\n", results.Meta.Provider) 103 | fmt.Println() 104 | fmt.Printf("NOTE: Just because bomber didn't find any vulnerabilities using the %v provider doesn't\n", results.Meta.Provider) 105 | fmt.Println("mean there are no vulnerabilities. Please try the other providers that bomber") 106 | fmt.Println("supports (osv, github, ossindex)") 107 | } 108 | } 109 | 110 | func renderSeveritySummary(summary models.Summary) { 111 | log.Println("Rendering Severity Summary") 112 | t := table.NewWriter() 113 | t.SetOutputMirror(os.Stdout) 114 | t.AppendHeader(table.Row{"Rating", "Count"}) 115 | if summary.Critical > 0 { 116 | t.AppendRow([]interface{}{"CRITICAL", summary.Critical}) 117 | } 118 | if summary.High > 0 { 119 | t.AppendRow([]interface{}{"HIGH", summary.High}) 120 | } 121 | if summary.Moderate > 0 { 122 | t.AppendRow([]interface{}{"MODERATE", summary.Moderate}) 123 | } 124 | if summary.Low > 0 { 125 | t.AppendRow([]interface{}{"LOW", summary.Low}) 126 | } 127 | if summary.Unspecified > 0 { 128 | t.AppendRow([]interface{}{"UNSPECIFIED", summary.Unspecified}) 129 | } 130 | if summary.Unspecified > 0 { 131 | t.AppendRow([]interface{}{"UNSPECIFIED", summary.Unspecified}) 132 | } 133 | t.SetStyle(table.StyleRounded) 134 | t.Style().Options.SeparateRows = true 135 | t.Render() 136 | } 137 | 138 | func vulnerabilityCount(packages []models.Package) (vulnCount int) { 139 | for _, r := range packages { 140 | vulns := len(r.Vulnerabilities) 141 | vulnCount += vulns 142 | } 143 | return 144 | } 145 | -------------------------------------------------------------------------------- /renderers/stdout/stdout_test.go: -------------------------------------------------------------------------------- 1 | package stdout 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/devops-kung-fu/common/util" 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/devops-kung-fu/bomber/models" 10 | ) 11 | 12 | func TestRenderer_Render(t *testing.T) { 13 | output := util.CaptureOutput(func() { 14 | packages := []models.Package{ 15 | { 16 | Purl: "pkg:golang/github.com/briandowns/spinner@v1.19.0", 17 | Vulnerabilities: []models.Vulnerability{ 18 | { 19 | ID: "Test", 20 | Severity: "CRITICAL", 21 | }, 22 | }, 23 | }, 24 | } 25 | renderer := Renderer{} 26 | renderer.Render(models.NewResults(packages, models.Summary{}, []models.ScannedFile{}, []string{"GPL"}, "0.0.0", "test", "low")) 27 | }) 28 | assert.NotNil(t, output) 29 | assert.Contains(t, output, "golang │ spinner │ v1.19.0 │ CRITICAL") 30 | } 31 | 32 | func Test_vulnerabilityCount(t *testing.T) { 33 | count := vulnerabilityCount([]models.Package{ 34 | { 35 | Purl: "test", 36 | Vulnerabilities: []models.Vulnerability{ 37 | { 38 | ID: "test", 39 | }, 40 | }, 41 | }, 42 | { 43 | Purl: "test", 44 | Vulnerabilities: []models.Vulnerability{ 45 | { 46 | ID: "test", 47 | }, 48 | { 49 | ID: "test", 50 | }, 51 | }, 52 | }, 53 | }) 54 | assert.Equal(t, 3, count) 55 | } 56 | 57 | func Test_renderSeveritySummary(t *testing.T) { 58 | output := util.CaptureOutput(func() { 59 | renderSeveritySummary(models.Summary{ 60 | Unspecified: 1, 61 | }) 62 | }) 63 | assert.NotNil(t, output) 64 | assert.Contains(t, output, "│ RATING") 65 | } 66 | 67 | func TestRenderFooter(t *testing.T) { 68 | output := util.CaptureOutput(func() { 69 | 70 | results := models.Results{ 71 | Summary: models.Summary{ 72 | Critical: 1, 73 | High: 2, 74 | Moderate: 3, 75 | Low: 4, 76 | }, 77 | Meta: models.Meta{ 78 | Provider: "test", 79 | SeverityFilter: "HIGH", 80 | }, 81 | } 82 | 83 | renderFooter(1, results) 84 | 85 | }) 86 | 87 | assert.Contains(t, output, "Rendering Severity Summary\n") 88 | assert.Contains(t, output, "CRITICAL │ 1") 89 | assert.Contains(t, output, "Only displaying vulnerabilities with a severity of") 90 | 91 | assert.NotNil(t, output) 92 | 93 | output = util.CaptureOutput(func() { 94 | 95 | results := models.Results{ 96 | Summary: models.Summary{ 97 | Critical: 1, 98 | High: 2, 99 | Moderate: 3, 100 | Low: 4, 101 | }, 102 | Meta: models.Meta{ 103 | Provider: "test", 104 | SeverityFilter: "HIGH", 105 | }, 106 | } 107 | 108 | renderFooter(0, results) 109 | }) 110 | assert.Contains(t, output, "\nNOTE: Just because bomber didn't find any vulnerabilities") 111 | 112 | } 113 | --------------------------------------------------------------------------------