├── .copywrite.hcl ├── .github ├── CODEOWNERS ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── build-and-release.yml │ └── golangci.yml ├── .gitignore ├── .go-version ├── .golangci.yaml ├── .goreleaser.yaml ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── META.d └── _summary.yaml ├── README.md ├── addlicense ├── .copywrite.hcl ├── LICENSE ├── README.md ├── generate-spdx-list.sh ├── main.go ├── main_test.go ├── spdx.go ├── spdx_test.go ├── testdata │ ├── custom.tpl │ ├── expected │ │ ├── .terraform.lock.hcl │ │ ├── Gemfile │ │ ├── file │ │ ├── file.bzl │ │ ├── file.c │ │ ├── file.cc │ │ ├── file.cjs │ │ ├── file.cpp │ │ ├── file.cs │ │ ├── file.css │ │ ├── file.dart │ │ ├── file.el │ │ ├── file.erl │ │ ├── file.go │ │ ├── file.groovy │ │ ├── file.gv │ │ ├── file.h │ │ ├── file.hcl │ │ ├── file.hh │ │ ├── file.hpp │ │ ├── file.hs │ │ ├── file.html │ │ ├── file.java │ │ ├── file.js │ │ ├── file.lisp │ │ ├── file.m │ │ ├── file.md │ │ ├── file.mjs │ │ ├── file.mm │ │ ├── file.php │ │ ├── file.proto │ │ ├── file.py │ │ ├── file.rb │ │ ├── file.rs │ │ ├── file.sass │ │ ├── file.scala │ │ ├── file.scss │ │ ├── file.sdl │ │ ├── file.swift │ │ ├── file.tf │ │ ├── file.txt │ │ ├── file.xml │ │ ├── file1.Dockerfile │ │ ├── file1.sh │ │ ├── file2.Dockerfile │ │ ├── file2.sh │ │ ├── file2.xml │ │ ├── file3.Dockerfile │ │ ├── file_generated.bzl │ │ ├── file_generated.go │ │ ├── multiline-comment.sentinel │ │ ├── multiline-sharp.sentinel │ │ ├── multiline-slash.sentinel │ │ ├── no-policy.sentinel │ │ └── singleline-slash.sentinel │ ├── initial │ │ ├── .terraform.lock.hcl │ │ ├── Gemfile │ │ ├── file │ │ ├── file.bzl │ │ ├── file.c │ │ ├── file.cc │ │ ├── file.cjs │ │ ├── file.cpp │ │ ├── file.cs │ │ ├── file.css │ │ ├── file.dart │ │ ├── file.el │ │ ├── file.erl │ │ ├── file.go │ │ ├── file.groovy │ │ ├── file.gv │ │ ├── file.h │ │ ├── file.hcl │ │ ├── file.hh │ │ ├── file.hpp │ │ ├── file.hs │ │ ├── file.html │ │ ├── file.java │ │ ├── file.js │ │ ├── file.lisp │ │ ├── file.m │ │ ├── file.md │ │ ├── file.mjs │ │ ├── file.mm │ │ ├── file.php │ │ ├── file.proto │ │ ├── file.py │ │ ├── file.rb │ │ ├── file.rs │ │ ├── file.sass │ │ ├── file.scala │ │ ├── file.scss │ │ ├── file.sdl │ │ ├── file.swift │ │ ├── file.tf │ │ ├── file.txt │ │ ├── file.xml │ │ ├── file1.Dockerfile │ │ ├── file1.sh │ │ ├── file2.Dockerfile │ │ ├── file2.sh │ │ ├── file2.xml │ │ ├── file3.Dockerfile │ │ ├── file_generated.bzl │ │ ├── file_generated.go │ │ ├── multiline-comment.sentinel │ │ ├── multiline-sharp.sentinel │ │ ├── multiline-slash.sentinel │ │ ├── no-policy.sentinel │ │ └── singleline-slash.sentinel │ └── multiyear_file.c ├── tmpl.go └── tmpl_test.go ├── cmd ├── debug.go ├── dispatch.go ├── headers.go ├── init.go ├── license.go ├── report.go ├── report_prs.go ├── report_repos.go ├── root.go ├── utils.go └── utils_test.go ├── config ├── config.go ├── config_test.go └── testdata │ ├── config_with_schema_version.hcl │ ├── dispatch │ └── full_dispatch.hcl │ ├── empty_config.hcl │ └── project │ ├── copyright_holder_only.hcl │ ├── copyright_year_only.hcl │ ├── full_project.hcl │ ├── license_only.hcl │ └── partial_project.hcl ├── dispatch └── dispatch.go ├── github ├── actions │ ├── core.go │ └── core_test.go ├── client.go └── repo.go ├── go.mod ├── go.sum ├── licensecheck ├── README.md ├── copyright.go ├── copyright_test.go ├── licensecheck.go ├── licensecheck_test.go └── licensetext.go ├── main.go └── repodata ├── README.md ├── repodata.go └── repodata_test.go /.copywrite.hcl: -------------------------------------------------------------------------------- 1 | schema_version = 1 2 | 3 | project { 4 | license = "MPL-2.0" 5 | copyright_year = 2022 6 | 7 | # (OPTIONAL) A list of globs that should not have copyright/license headers. 8 | # Supports doublestar glob patterns for more flexibility in defining which 9 | # files or folders should be ignored 10 | header_ignore = [ 11 | "**/testdata/**", 12 | 13 | # Forked and modified project 14 | "addlicense/**", 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Each line is a file pattern followed by one or more owners. 2 | # More on CODEOWNERS files: https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners 3 | 4 | # Default owner 5 | * @hashicorp/team-ip-compliance 6 | 7 | # Add override rules below. Each line is a file/folder pattern followed by one or more owners. 8 | # Being an owner means those groups or individuals will be added as reviewers to PRs affecting 9 | # those areas of the code. 10 | # Examples: 11 | # /docs/ @docs-team 12 | # *.js @js-team 13 | # *.go @go-team -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | # Update dependencies weekly 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: github-actions 9 | directory: / 10 | schedule: 11 | interval: weekly 12 | - package-ecosystem: gomod 13 | directory: / 14 | schedule: 15 | interval: weekly 16 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### :hammer_and_wrench: Description 2 | 3 | 4 | 5 | 6 | ### :link: External Links 7 | 8 | 9 | 10 | 11 | ### :+1: Definition of Done 12 | 13 | - [ ] New functionality works? 14 | - [ ] Tests added? 15 | 16 | ### :thinking: Can be merged upon approval? 17 | 18 | :white_check_mark: 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | 7 | permissions: 8 | contents: write # Upload GitHub Release Artifacts 9 | issues: write # Close related issues 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 17 | 18 | - name: Install Go 19 | uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 # v4.0.0 20 | with: 21 | go-version-file: '.go-version' 22 | 23 | - name: Run GoReleaser 24 | uses: goreleaser/goreleaser-action@286f3b13b1b49da4ac219696163fb8c1c93e1200 # v6.0.0 25 | with: 26 | distribution: goreleaser 27 | version: latest 28 | args: release --clean 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} 31 | HOMEBREW_COMMIT_AUTHOR_NAME: ${{ secrets.HOMEBREW_COMMIT_AUTHOR_NAME }} 32 | HOMEBREW_COMMIT_EMAIL: ${{ secrets.HOMEBREW_COMMIT_EMAIL }} 33 | -------------------------------------------------------------------------------- /.github/workflows/golangci.yml: -------------------------------------------------------------------------------- 1 | name: golangci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | permissions: 10 | contents: read 11 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 12 | # pull-requests: read 13 | 14 | jobs: 15 | golangci-lint: 16 | name: lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 21 | 22 | - name: Install Go 23 | uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 # v4.0.0 24 | with: 25 | go-version-file: '.go-version' 26 | 27 | - name: run go lint 28 | uses: golangci/golangci-lint-action@639cd343e1d3b897ff35927a75193d57cfcba299 # v3.6.0 29 | with: 30 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version 31 | version: latest 32 | 33 | golang-test: 34 | name: test 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: Checkout code 38 | uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 39 | 40 | - name: Install Go 41 | uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 # v4.0.0 42 | with: 43 | go-version-file: '.go-version' 44 | 45 | - name: run go test 46 | run: go test -v -coverprofile=coverage.out ./... 47 | 48 | - name: upload coverage report 49 | uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 50 | with: 51 | path: coverage.out 52 | name: coverage-report 53 | 54 | - name: display coverage report 55 | run: go tool cover -func=coverage.out 56 | 57 | go-mod-tidy: 58 | name: tidy 59 | runs-on: ubuntu-latest 60 | steps: 61 | - name: Checkout code 62 | uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 63 | 64 | - name: Install Go 65 | uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 # v4.0.0 66 | with: 67 | go-version-file: '.go-version' 68 | 69 | - name: run go mod tidy 70 | run: go mod tidy 71 | 72 | build: 73 | name: build executables 74 | runs-on: 'ubuntu-latest' 75 | steps: 76 | - name: Checkout code 77 | uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 78 | 79 | - name: Install Go 80 | uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 # v4.0.0 81 | with: 82 | go-version-file: '.go-version' 83 | 84 | - name: Run GoReleaser 85 | uses: goreleaser/goreleaser-action@7ec5c2b0c6cdda6e8bbb49444bc797dd33d74dd8 # v5.0.0 86 | with: 87 | version: latest 88 | args: release --clean --skip=publish --snapshot 89 | 90 | - name: Upload build artifacts 91 | uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 92 | with: 93 | name: copywrite 94 | path: dist/* 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .vscode 3 | .DS_Store 4 | 5 | # output from running the program 6 | repos.csv 7 | 8 | # Ignore built binaries 9 | copywrite 10 | 11 | # Ignore local configs 12 | .env 13 | -------------------------------------------------------------------------------- /.go-version: -------------------------------------------------------------------------------- 1 | 1.23 2 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | linters: 5 | disable: 6 | # The following are redundant with the built-in 'unused' linter 7 | # https://github.com/golangci/golangci-lint/issues/1841 8 | - deadcode 9 | - structcheck 10 | - varcheck 11 | # The following are marked as deprecated by golangci-lint 12 | - exhaustivestruct 13 | - golint 14 | - interfacer 15 | - maligned 16 | - scopelint 17 | linters-settings: 18 | # See the dedicated "linters-settings" documentation section. 19 | disable: strings 20 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | version: 2 4 | before: 5 | hooks: 6 | - go mod tidy 7 | builds: 8 | - env: 9 | - CGO_ENABLED=0 10 | goos: 11 | - linux 12 | - darwin 13 | - windows 14 | goarch: 15 | - amd64 16 | - arm64 17 | main: main.go 18 | ldflags: 19 | - -s -w -X github.com/hashicorp/copywrite/cmd.version={{.Version}} -X github.com/hashicorp/copywrite/cmd.commit={{.ShortCommit}} 20 | binary: copywrite 21 | archives: 22 | - name_template: >- 23 | {{- .ProjectName }}_ 24 | {{- .Version }}_ 25 | {{- .Os }}_ 26 | {{- if eq .Arch "amd64" }}x86_64{{- else }}{{ .Arch }}{{ end }} 27 | {{- if .Arm }}v{{ .Arm }}{{ end -}} 28 | format_overrides: 29 | - goos: windows 30 | format: zip 31 | checksum: 32 | name_template: 'SHA256SUMS' 33 | release: 34 | github: 35 | owner: hashicorp 36 | name: copywrite 37 | header: | 38 | - Attached to this release are compressed builds of the `copywrite` client 39 | 40 | # Auto-publish to the HashiCorp homebrew tap: https://github.com/hashicorp/homebrew-tap 41 | brews: 42 | - name: copywrite 43 | repository: 44 | owner: hashicorp 45 | name: homebrew-tap 46 | commit_author: 47 | name: '{{ .Env.HOMEBREW_COMMIT_AUTHOR_NAME }}' 48 | email: '{{ .Env.HOMEBREW_COMMIT_EMAIL }}' 49 | homepage: 'https://github.com/hashicorp/copywrite' 50 | description: 'copywrite -- utilities for managing copyright headers and license files for GitHub repos' 51 | license: 'MPL-2.0' 52 | directory: Formula 53 | test: | 54 | system "#{bin}/copywrite --version" 55 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | --- 5 | repos: 6 | - repo: https://github.com/hashicorp/copywrite 7 | rev: v0.15.0 # Use any release tag 8 | hooks: 9 | - id: copywrite-headers 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v2.3.0 12 | hooks: 13 | - id: end-of-file-fixer 14 | exclude: ^addLicense/testdata/ 15 | - id: trailing-whitespace 16 | exclude: ^addLicense/testdata/ 17 | - repo: https://github.com/dnephin/pre-commit-golang 18 | rev: v0.5.0 19 | hooks: 20 | - id: go-fmt 21 | - id: go-vet 22 | exclude: ^addLicense/testdata/ 23 | - id: go-imports 24 | - id: go-cyclo 25 | args: [-over=20] 26 | - id: validate-toml 27 | - id: no-go-testing 28 | - id: golangci-lint 29 | # Disabling until gocritic fixes the "Unexpected package creation during export data loading" 30 | # error that shows up when using packages containing generics 31 | # - id: go-critic 32 | - id: go-unit-tests 33 | - id: go-build 34 | - id: go-mod-tidy 35 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: add-headers 2 | name: Add copyright headers 3 | description: Adds missing copyright headers to all source code files 4 | entry: go run . 5 | language: golang 6 | args: [headers] 7 | 8 | - id: check-headers 9 | name: Validate copyright headers 10 | description: Checks if any copyright headers are missing, but does not make changes 11 | entry: go run . 12 | language: golang 13 | args: [headers --plan] 14 | 15 | - id: add-license 16 | name: Add or fix repo license 17 | description: Adds or updates a non-compliant LICENSE file 18 | entry: go run . 19 | language: golang 20 | args: [license] 21 | 22 | - id: check-license 23 | name: Validate repo license 24 | description: Checks if a LICENSE file is valid, but does not make changes 25 | entry: go run . 26 | language: golang 27 | args: [license --plan] 28 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "golang.go", // Enables rich Golang autocomplete and intellisense 4 | "mechatroner.rainbow-csv", // Makes CSVs easier to read 5 | "cschleiden.vscode-github-actions", // Provides autocomplete for GitHub Actions 6 | "DavidAnson.vscode-markdownlint", // Markdown linting for consistency 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Editor settings 3 | "editor.formatOnSave": true, 4 | "files.insertFinalNewline": true, 5 | "files.trimFinalNewlines": true, 6 | // json auto-formatting can get weird 7 | "[json]": { 8 | "editor.formatOnSave": false 9 | }, 10 | // Go settings 11 | "go.buildOnSave": "off", 12 | "go.lintOnSave": "workspace", 13 | "go.vetOnSave": "workspace", 14 | "go.buildTags": "", 15 | "go.buildFlags": [], 16 | "go.lintTool": "golint", 17 | "go.lintFlags": [], 18 | "go.vetFlags": [], 19 | "go.testOnSave": false, 20 | "go.coverOnSave": false, 21 | "go.useCodeSnippetsOnFunctionSuggest": true, 22 | "go.formatTool": "gofmt", 23 | "go.formatFlags": [], 24 | "go.inferGopath": false, 25 | "go.gocodeAutoBuild": false, 26 | "go.testFlags": [ 27 | "-v" 28 | ], 29 | "cSpell.words": [ 30 | "addlicense", 31 | "busa", 32 | "checkonly", 33 | "haya", 34 | "headerignore", 35 | "licensef", 36 | "SPDXID" 37 | ], 38 | "markdownlint.ignore": [ 39 | ".github/pull_request_template.md" 40 | ], 41 | "markdownlint.config": { 42 | "line-length": false 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /META.d/_summary.yaml: -------------------------------------------------------------------------------- 1 | schema: 1.1 2 | partition: internal-platform 3 | category: tooling 4 | summary: 5 | owner: team-productivity-systems-and-services 6 | description: | 7 | Utility for managing copyright headers and license files across repos 8 | visibility: external 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # copywrite 2 | 3 | This repo provides utilities for managing copyright headers and license files 4 | across many repos at scale. 5 | 6 | You can use it to add or validate copyright headers on source code files, add a 7 | LICENSE file to a repo, report on what licenses repos are using, and more. 8 | 9 | ## Getting Started 10 | 11 | The easiest way to get started is to use Homebrew: 12 | 13 | ```sh 14 | brew tap hashicorp/tap 15 | brew install hashicorp/tap/copywrite 16 | ``` 17 | 18 | Installers for Windows, Linux, and MacOS are also available on the [releases](https://github.com/hashicorp/copywrite/releases) page. 19 | 20 | ## CLI Usage 21 | 22 | This Go app is consumable as a command-line tool. Currently, the following subcommands are available: 23 | 24 | ```none 25 | ❯ copywrite 26 | Copywrite provides utilities for managing copyright headers and license 27 | files in HashiCorp repos. 28 | 29 | You can use it to report on what licenses repos are using, add LICENSE files, 30 | and add or validate the presence of copyright headers on source code files. 31 | 32 | Usage: 33 | copywrite [command] 34 | 35 | Common Commands: 36 | headers Adds missing copyright headers to all source code files 37 | init Generates a .copywrite.hcl config for a new project 38 | license Validates that a LICENSE file is present and remediates any issues if found 39 | 40 | Additional Commands: 41 | completion Generate the autocompletion script for the specified shell 42 | debug Prints env-specific debug information about copywrite 43 | dispatch Dispatches audit jobs for a list of repos 44 | help Help about any command 45 | report Performs a variety of reporting tasks 46 | 47 | Flags: 48 | --config string config file (default is .copywrite.hcl in current directory) 49 | -h, --help help for copywrite 50 | -v, --version version for copywrite 51 | 52 | Use "copywrite [command] --help" for more information about a command. 53 | ``` 54 | 55 | To get started with Copywrite on a new project, run `copywrite init`, which will 56 | interactively help generate a `.copywrite.hcl` config file to add to Git. 57 | 58 | The most common command you will use is `copywrite headers`, which will automatically 59 | scan all files in your repo and copyright headers to any that are missing: 60 | 61 | ```sh 62 | copywrite headers --spdx "MPL-2.0" 63 | ``` 64 | 65 | You may omit the `--spdx` flag if you add a `.copywrite.hcl` config, as outlined 66 | [here](#config-structure). 67 | 68 | ### `--plan` Flag 69 | 70 | Both the `headers` and `license` commands allow you to use a `--plan` flag, which 71 | performs a dry-run and will outline what changes would be made. This flag also 72 | returns a non-zero exit code if any changes are needed. As such, it can be used 73 | to validate if a repo is in compliance or not. 74 | 75 | ## Config Structure 76 | 77 | > :bulb: You can automatically generate a new `.copywrite.hcl` config with the 78 | `copywrite init` command. 79 | 80 | A `.copywrite.hcl` file can be referenced to provide configuration information 81 | for a given project. This file should be specific to each repo and checked into 82 | git. If no configuration file is present, default values will be used throughout 83 | the `copywrite` application. An example config structure is shown below: 84 | 85 | ```hcl 86 | # (OPTIONAL) Overrides the copywrite config schema version 87 | # Default: 1 88 | schema_version = 1 89 | 90 | project { 91 | # (OPTIONAL) SPDX-compatible license identifier 92 | # Leave blank if you don't wish to license the project 93 | # Default: "MPL-2.0" 94 | license = "MPL-2.0" 95 | 96 | # (OPTIONAL) Represents the copyright holder used in all statements 97 | # Default: HashiCorp, Inc. 98 | # copyright_holder = "" 99 | 100 | # (OPTIONAL) Represents the year that the project initially began 101 | # Default: 102 | # copyright_year = 0 103 | 104 | # (OPTIONAL) A list of globs that should not have copyright or license headers . 105 | # Supports doublestar glob patterns for more flexibility in defining which 106 | # files or folders should be ignored 107 | # Default: [] 108 | header_ignore = [ 109 | # "vendor/**", 110 | # "**autogen**", 111 | ] 112 | 113 | # (OPTIONAL) Links to an upstream repo for determining repo relationships 114 | # This is for special cases and should not normally be set. 115 | # Default: "" 116 | # upstream = "hashicorp/" 117 | } 118 | 119 | ``` 120 | 121 | ## GitHub Authentication 122 | 123 | Some commands interact directly with GitHub's API (especially when a 124 | `.copywrite.hcl` config is not present for the project). In order to use these 125 | commands successfully, multiple mechanisms are available to provide GitHub 126 | credentials and are prioritized in the following order: 127 | 128 | - GitHub App credentials can be supplied via the `APP_ID`, `INSTALLATION_ID`, and `APP_PEM` environment variables or a `.env` file. 129 | - A `GITHUB_TOKEN` environment variable can be used with a Personal Access Token 130 | - If you use the [GitHub CLI](https://cli.github.com/), auth information can automatically be used 131 | 132 | If none of the above methods work, `copywrite` will default to using an **unauthenticated** client. 133 | 134 | GitHub credentials are purposely excluded from the `.copywrite.hcl` config, as 135 | that file is meant to be specific to each project and checked in to its repo. 136 | 137 | ## GitHub Action 138 | 139 | To make it easier to use `copywrite` in your own CI jobs (e.g., to add a PR check), 140 | you can make use of the [hashicorp/setup-copywrite](https://github.com/marketplace/actions/setup-copywrite) GitHub Action. It 141 | automatically installs the binary and adds it to your `$PATH` so you can call it 142 | freely in later steps. 143 | 144 | ```yaml 145 | - name: Setup Copywrite 146 | uses: hashicorp/setup-copywrite@867a1a2a064a0626db322392806428f7dc59cb3e # v1.1.2 147 | 148 | - name: Check Header Compliance 149 | run: copywrite headers --plan 150 | ``` 151 | 152 | :bulb: Running the copywrite command with the `--plan` flag will return a non-zero exit code if the repo is out of compliance. 153 | 154 | ## Pre-Commit Hooks 155 | 156 | Copywrite can be used as a [Pre-Commit](https://pre-commit.com) Hook for those 157 | looking to add copyright headers during local development. A list of supported 158 | hooks can be found in [here](./.pre-commit-hooks.yaml), but the most common use 159 | case for adding missing copyright headers can be done by adding the following 160 | snippet to your repo's `.pre-commit-config.yaml`: 161 | 162 | ```yaml 163 | - repo: https://github.com/hashicorp/copywrite 164 | rev: v0.15.0 # Use any release tag 165 | hooks: 166 | - id: copywrite-headers 167 | ``` 168 | 169 | ## Debugging 170 | 171 | Copywrite supports several built-in features to aid with debugging. The first 172 | and most commonly used one is configurable log levels. Copywrite checks the 173 | `COPYWRITE_LOG_LEVEL` environment variable to determine which verbosity to use. 174 | The following log levels are supported: 175 | 176 | - `trace` 177 | - `debug` 178 | - `info` 179 | - `warn` 180 | - `error` 181 | - `off` (disables logging) 182 | 183 | Copywrite also checks for if the `RUNNER_DEBUG=1` environment variable is set, 184 | which will cause it to default to debug-level logging. This environment variable 185 | is set by Github Actions when in debug mode, and can be a useful default. 186 | The `COPYWRITE_LOG_LEVEL` setting takes precedence, however. 187 | 188 | It is often useful to introspect information about the state Copywrite finds 189 | itself in. The `copywrite debug` command can print the running configuration, 190 | whether or not a config file was loaded, what GitHub auth type is in use, and 191 | more. No sensitive information is printed, however. 192 | 193 | ## Development 194 | 195 | To maintain a consistent developer experience, this repo comes bundled with VS Code settings. When opening the repo for the first time, you will be asked if you want to install [suggested extensions](./.vscode/extensions.json) and your workspace will be pre-configured with consistent format-on-save [settings](./.vscode/settings.json). 196 | 197 | Before committing code, this repo has been setup to check Go files using [pre-commit git hooks](https://pre-commit.com/). To leverage pre-commit, developers must install pre-commit and associated tools locally: 198 | 199 | ```bash 200 | brew install pre-commit golangci-lint go-critic 201 | ``` 202 | 203 | Verify install went successfully with: 204 | 205 | ```bash 206 | pre-commit --version 207 | ``` 208 | 209 | Once you verify `pre-commit` is installed locally, you can use pre-commit git hooks by installing them in this repo: 210 | 211 | ```bash 212 | pre-commit install 213 | ``` 214 | -------------------------------------------------------------------------------- /addlicense/.copywrite.hcl: -------------------------------------------------------------------------------- 1 | schema_version = 1 2 | 3 | project { 4 | license = "Apache-2.0" 5 | copyright_year = 2016 6 | 7 | # (OPTIONAL) A list of globs that should not have copyright/license headers. 8 | # Supports doublestar glob patterns for more flexibility in defining which 9 | # files or folders should be ignored 10 | header_ignore = [ 11 | "**", # skip everything for now 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /addlicense/README.md: -------------------------------------------------------------------------------- 1 | # addlicense 2 | 3 | :warning: This is an internally-maintained fork of github.com/google/addLicense. 4 | It is meant only for HashiCorp usage and is not supported. 5 | 6 | --- 7 | 8 | ## license 9 | 10 | Apache 2.0 11 | 12 | This is not an official Google product. 13 | -------------------------------------------------------------------------------- /addlicense/generate-spdx-list.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | # Copyright (c) HashiCorp, Inc. 3 | 4 | set -euo pipefail 5 | 6 | # This is the officially updated list of SPDX license identifiers in JSON form 7 | licenses=$(curl --silent https://raw.githubusercontent.com/spdx/license-list-data/master/json/licenses.json) 8 | 9 | OUTPUT_FILE_PATH="./spdx.go" 10 | 11 | 12 | ############### 13 | # File Header # 14 | ############### 15 | cat <> $OUTPUT_FILE_PATH 33 | } 34 | 35 | echo $licenses | jq '.licenses[] | select(.isDeprecatedLicenseId == false)' -c | sort | while read -r l ;do 36 | # this is super slow... but this script is basically never reran, sooo... meh? 37 | id=$(jq '.licenseId' -r <<< "$l") 38 | name=$(jq '.name' -r <<< "$l") 39 | link=$(jq '.reference' -r <<< "$l") 40 | outfile "" 41 | outfile " // $name" 42 | outfile " // $link" 43 | outfile " \"$id\"," 44 | done 45 | 46 | 47 | ############### 48 | # File footer # 49 | ############### 50 | cat < 18 | 19 | int main() { 20 | printf("Hello world\n"); 21 | return 0; 22 | } 23 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.cc: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #include 16 | using namespace std; 17 | 18 | int main() { 19 | cout << "Hello World!"; 20 | return 0; 21 | } 22 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | function dummy() { 18 | console.log('hello world!'); 19 | } 20 | 21 | module.exports = dummy 22 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.cpp: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #include 16 | using namespace std; 17 | 18 | int main() { 19 | cout << "Hello World!"; 20 | return 0; 21 | } 22 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | public class Hello 16 | { 17 | public static void Main() { 18 | System.Console.WriteLine("Hello, World!"); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | .div { 18 | color: red; 19 | } 20 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | void main() { 16 | print('Hello World!'); 17 | } 18 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.el: -------------------------------------------------------------------------------- 1 | ;; Copyright 2018 Google LLC 2 | ;; 3 | ;; Licensed under the Apache License, Version 2.0 (the "License"); 4 | ;; you may not use this file except in compliance with the License. 5 | ;; You may obtain a copy of the License at 6 | ;; 7 | ;; http://www.apache.org/licenses/LICENSE-2.0 8 | ;; 9 | ;; Unless required by applicable law or agreed to in writing, software 10 | ;; distributed under the License is distributed on an "AS IS" BASIS, 11 | ;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | ;; See the License for the specific language governing permissions and 13 | ;; limitations under the License. 14 | 15 | (message "Hello world!") 16 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.erl: -------------------------------------------------------------------------------- 1 | % Copyright 2018 Google LLC 2 | % 3 | % Licensed under the Apache License, Version 2.0 (the "License"); 4 | % you may not use this file except in compliance with the License. 5 | % You may obtain a copy of the License at 6 | % 7 | % http://www.apache.org/licenses/LICENSE-2.0 8 | % 9 | % Unless required by applicable law or agreed to in writing, software 10 | % distributed under the License is distributed on an "AS IS" BASIS, 11 | % WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | % See the License for the specific language governing permissions and 13 | % limitations under the License. 14 | 15 | -module(hello). 16 | -export([hello_world/0]). 17 | 18 | hello_world() -> io:fwrite("hello, world\n"). 19 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import "fmt" 18 | 19 | func main() { 20 | fmt.Println("Hello, World!") 21 | } 22 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.groovy: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | def dummy_function() { 16 | println "Hello world" 17 | } 18 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.gv: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | graph ethane { 18 | C_0 -- H_0; 19 | C_0 -- H_1; 20 | C_0 -- H_2; 21 | C_0 -- C_1; 22 | C_1 -- H_3; 23 | C_1 -- H_4; 24 | C_1 -- H_5; 25 | } 26 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.h: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | #define SOMETHING 18 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.hcl: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | group "default" { 16 | targets = ["build"] 17 | } 18 | 19 | target "build" { 20 | dockerfile = "./Dockerfile" 21 | output = ["type=docker"] 22 | } 23 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.hh: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #define SOMETHING 16 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.hpp: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #define SOMETHING 16 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.hs: -------------------------------------------------------------------------------- 1 | -- Copyright 2018 Google LLC 2 | -- 3 | -- Licensed under the Apache License, Version 2.0 (the "License"); 4 | -- you may not use this file except in compliance with the License. 5 | -- You may obtain a copy of the License at 6 | -- 7 | -- http://www.apache.org/licenses/LICENSE-2.0 8 | -- 9 | -- Unless required by applicable law or agreed to in writing, software 10 | -- distributed under the License is distributed on an "AS IS" BASIS, 11 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | -- See the License for the specific language governing permissions and 13 | -- limitations under the License. 14 | 15 | module Main where 16 | 17 | main = putStrLn "Hello, World!" 18 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.html: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 |

Hello World!

19 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | public class HelloWorld { 18 | 19 | public static void main(String[] args) { 20 | System.out.println("Hello, World"); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | function dummy() { 18 | console.log('hello world!'); 19 | } 20 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.lisp: -------------------------------------------------------------------------------- 1 | ;; Copyright 2018 Google LLC 2 | ;; 3 | ;; Licensed under the Apache License, Version 2.0 (the "License"); 4 | ;; you may not use this file except in compliance with the License. 5 | ;; You may obtain a copy of the License at 6 | ;; 7 | ;; http://www.apache.org/licenses/LICENSE-2.0 8 | ;; 9 | ;; Unless required by applicable law or agreed to in writing, software 10 | ;; distributed under the License is distributed on an "AS IS" BASIS, 11 | ;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | ;; See the License for the specific language governing permissions and 13 | ;; limitations under the License. 14 | 15 | ; hello world lisp program. 16 | (print "Hello World") 17 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.m: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #import 16 | 17 | int main (int argc, const char * argv[]) 18 | { 19 | NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; 20 | NSLog (@"Hello, World!"); 21 | [pool drain]; 22 | return 0; 23 | } 24 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.md: -------------------------------------------------------------------------------- 1 | # Markdown 2 | 3 | This is a markdown file and should not be modified. 4 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | function dummy() { 18 | console.log('hello world!'); 19 | } 20 | 21 | export default dummy 22 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.mm: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #import 16 | 17 | int main (int argc, const char * argv[]) 18 | { 19 | NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; 20 | NSLog (@"Hello, World!"); 21 | [pool drain]; 22 | return 0; 23 | } 24 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file.php: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | one 19 | 20 | 21 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file1.Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | FROM scratch 16 | CMD ["echo", "hello world"] 17 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file1.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright 2018 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | echo hello 17 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file2.Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.3 2 | # Copyright 2018 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | FROM scratch 17 | CMD ["echo", "hello world"] 18 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file2.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/go run prog.go $* 2 | # Copyright 2018 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file2.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | one 20 | 21 | 22 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file3.Dockerfile: -------------------------------------------------------------------------------- 1 | # escape=` 2 | # Copyright 2018 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | FROM microsoft/nanoserver 17 | COPY testfile.txt c:\ 18 | RUN dir c:\ 19 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file_generated.bzl: -------------------------------------------------------------------------------- 1 | """ 2 | cargo-raze crate workspace functions 3 | 4 | DO NOT EDIT! Replaced on runs of cargo-raze 5 | """ 6 | load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") 7 | 8 | def _new_http_archive(name, **kwargs): 9 | if not native.existing_rule(name): 10 | http_archive(name=name, **kwargs) 11 | 12 | def raze_fetch_remote_crates(): 13 | 14 | _new_http_archive( 15 | name = "raze__log__0_4_11", 16 | url = "https://crates-io.s3-us-west-1.amazonaws.com/crates/log/log-0.4.11.crate", 17 | type = "tar.gz", 18 | sha256 = "4fabed175da42fed1fa0746b0ea71f412aa9d35e76e95e59b192c64b9dc2bf8b", 19 | strip_prefix = "log-0.4.11", 20 | build_file = Label("//bazel/cargo/remote:log-0.4.11.BUILD"), 21 | ) 22 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/file_generated.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type foo"; DO NOT EDIT. 2 | 3 | package main 4 | 5 | func (f Foo) String() string { 6 | return "foo" 7 | } 8 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/multiline-comment.sentinel: -------------------------------------------------------------------------------- 1 | /* This policy requires that the `require_lowercase_characters` attribute of the `aws_iam_account_password_policy` 2 | resource is according to CIS standards. */ 3 | 4 | # Copyright 2018 Google LLC 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | 18 | # Imports 19 | 20 | import "tfconfig/v2" as tfconfig 21 | import "tfplan/v2" as tfplan 22 | import "tfresources" as tf 23 | import "report" as report 24 | import "collection" as collection 25 | import "collection/maps" as maps 26 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/multiline-sharp.sentinel: -------------------------------------------------------------------------------- 1 | # This policy requires that the `require_lowercase_characters` attribute of the `aws_iam_account_password_policy` 2 | # resource is according to CIS standards. 3 | # 4 | # another text 5 | 6 | # Copyright 2018 Google LLC 7 | # 8 | # Licensed under the Apache License, Version 2.0 (the "License"); 9 | # you may not use this file except in compliance with the License. 10 | # You may obtain a copy of the License at 11 | # 12 | # http://www.apache.org/licenses/LICENSE-2.0 13 | # 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | # See the License for the specific language governing permissions and 18 | # limitations under the License. 19 | 20 | # Imports 21 | 22 | import "tfconfig/v2" as tfconfig 23 | import "tfplan/v2" as tfplan 24 | import "tfresources" as tf 25 | import "report" as report 26 | import "collection" as collection 27 | import "collection/maps" as maps 28 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/multiline-slash.sentinel: -------------------------------------------------------------------------------- 1 | // This policy requires that the `require_lowercase_characters` attribute of the `aws_iam_account_password_policy` 2 | // resource is according to CIS standards. 3 | 4 | # Copyright 2018 Google LLC 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | 18 | # Imports 19 | 20 | import "tfconfig/v2" as tfconfig 21 | import "tfplan/v2" as tfplan 22 | import "tfresources" as tf 23 | import "report" as report 24 | import "collection" as collection 25 | import "collection/maps" as maps 26 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/no-policy.sentinel: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import "tfconfig/v2" as tfconfig 16 | import "tfplan/v2" as tfplan 17 | import "tfresources" as tf 18 | import "report" as report 19 | import "collection" as collection 20 | import "collection/maps" as maps 21 | 22 | # Constants 23 | 24 | const = { 25 | "resource_efs_file_system": "aws_efs_file_system", 26 | "policy_name": "efs-encryption-at-rest-enabled", 27 | "kms_key_id": "kms_key_id", 28 | "constant_value": "constant_value", 29 | "encrypted": "encrypted", 30 | "encrypted_attr_violation_msg": "Attribute 'encrypted' should be true for 'aws_efs_file_system' resources. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/efs-controls.html#efs-1 for more details.", 31 | "kms_key_id_attr_violation_msg": "Attribute 'kms_key_id' should be non empty for 'aws_efs_file_system' resources. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/efs-controls.html#efs-1 for more details.", 32 | } 33 | 34 | # Functions 35 | 36 | build_violation_object = func(res, message) { 37 | return { 38 | "address": res.address, 39 | "module_address": res.module_address, 40 | "message": message, 41 | } 42 | } 43 | 44 | # Variables 45 | 46 | efs_file_systems_from_plan = tf.plan(tfplan.planned_values.resources).type(const.resource_efs_file_system).resources 47 | 48 | # Filter out aws_efs_file_systems that have invalid 'encrypted' attribute 49 | non_encrypted_file_systems = collection.reject(efs_file_systems_from_plan, func(res) { 50 | encrypted_val = maps.get(res, "values.encrypted", false) 51 | return encrypted_val is true 52 | }) 53 | 54 | non_encrypted_file_systems_violations = map non_encrypted_file_systems as _, res { 55 | build_violation_object(res, const.encrypted_attr_violation_msg) 56 | } 57 | 58 | efs_file_systems_from_configs = tf.config(tfconfig.resources).type(const.resource_efs_file_system).resources 59 | 60 | # Filter out aws_efs_file_systems that have empty 'kms_key_id' attribute 61 | efs_resources_with_empty_kms_key_ids = collection.reject(efs_file_systems_from_configs, func(res) { 62 | key_path = "config.kms_key_id" 63 | return maps.get(res, key_path, false) is not false and 64 | maps.get(res, key_path + "." + const.constant_value, false) is not "" 65 | }) 66 | 67 | efs_resources_with_empty_kms_key_ids_violations = map efs_resources_with_empty_kms_key_ids as _, res { 68 | build_violation_object(res, const.kms_key_id_attr_violation_msg) 69 | } 70 | 71 | summary = { 72 | "policy_name": const.policy_name, 73 | "violations": non_encrypted_file_systems_violations + efs_resources_with_empty_kms_key_ids_violations, 74 | } 75 | 76 | # Outputs 77 | 78 | print(report.generate_policy_report(summary)) 79 | 80 | # Rules 81 | 82 | verify_non_encrypted_file_systems = rule { 83 | non_encrypted_file_systems_violations is empty 84 | } 85 | 86 | verify_kms_key_referencing_file_systems = rule { 87 | efs_resources_with_empty_kms_key_ids_violations is empty 88 | } 89 | 90 | main = rule { 91 | verify_non_encrypted_file_systems and verify_kms_key_referencing_file_systems 92 | } 93 | -------------------------------------------------------------------------------- /addlicense/testdata/expected/singleline-slash.sentinel: -------------------------------------------------------------------------------- 1 | // This policy requires that the `require_lowercase_characters` attribute of the `aws_iam_account_password_policy` 2 | 3 | # Copyright 2018 Google LLC 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # Imports 18 | 19 | import "tfconfig/v2" as tfconfig 20 | import "tfplan/v2" as tfplan 21 | import "tfresources" as tf 22 | import "report" as report 23 | import "collection" as collection 24 | import "collection/maps" as maps 25 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/aws" { 5 | version = "3.69.0" 6 | constraints = ">= 2.65.0, >= 2.70.0, >= 3.0.0" 7 | hashes = [ 8 | "h1:1ud3VckbhSQ250tv58JCM9i1HKP05eijG4PEnU5PH7s=", 9 | "h1:DFUb87/IK9l6anGAagwkDZ3x62p18JCljU8he5SfLrM=", 10 | "zh:0cedd84ba908ba7190052b16cd7f70c41b0e2c2e914e54eecf2e3dae193f47fa", 11 | "zh:14b89bac6412e20d415fe67d5f2eaa1414d9bbf75a5bd8fc963f6ab8e3b8b1a0", 12 | "zh:159131129edab7ea118dee7d6daf1bfe4615f200a2d5120deb8369cbd2c4b598", 13 | "zh:32a3a35964c9becb167180df905f34eb0cae7c30e00452527a0e600ee95c033f", 14 | "zh:5330374066ca27d9a00ca667c81183c1dbfa0fcfdd5c1797a6185b76bc7c13bc", 15 | "zh:78b75c45b7c660efaf89428ca988a77a4f55eba359f95ed7a54efe87fad1ab8b", 16 | "zh:81f723c3f33dc0761ed12b025c1f411fe22f2c6a97e22f4adeb10f7668a5df8f", 17 | "zh:98053adb091233fea8c1a82768dfce994e8407e2cb948d28ff88865d2d9a4dcd", 18 | "zh:a52866826b51c0b4cb6b970cb3328542846e108c8f4d24c090d7ca0ffa341e44", 19 | "zh:a9923cbdf30e9b66f889fef22e1f4b657d9ac1a48812f476ef841405a3c11525", 20 | "zh:c079f98be9b8456e6eae6c07c5dcb84ecbcbb70b2f361f1c6f9c3ba90366d905", 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/Gemfile: -------------------------------------------------------------------------------- 1 | puts "Hello world!" 2 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file: -------------------------------------------------------------------------------- 1 | This is a file without extension 2 | and should not be modified. 3 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.bzl: -------------------------------------------------------------------------------- 1 | # Say hello 2 | def hello(): 3 | print("Hello world!") 4 | 5 | hello() 6 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | int main() { 4 | printf("Hello world\n"); 5 | return 0; 6 | } 7 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.cc: -------------------------------------------------------------------------------- 1 | #include 2 | using namespace std; 3 | 4 | int main() { 5 | cout << "Hello World!"; 6 | return 0; 7 | } 8 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.cjs: -------------------------------------------------------------------------------- 1 | function dummy() { 2 | console.log('hello world!'); 3 | } 4 | 5 | module.exports = dummy 6 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | using namespace std; 3 | 4 | int main() { 5 | cout << "Hello World!"; 6 | return 0; 7 | } 8 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.cs: -------------------------------------------------------------------------------- 1 | public class Hello 2 | { 3 | public static void Main() { 4 | System.Console.WriteLine("Hello, World!"); 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.css: -------------------------------------------------------------------------------- 1 | .div { 2 | color: red; 3 | } 4 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.dart: -------------------------------------------------------------------------------- 1 | void main() { 2 | print('Hello World!'); 3 | } 4 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.el: -------------------------------------------------------------------------------- 1 | (message "Hello world!") 2 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.erl: -------------------------------------------------------------------------------- 1 | -module(hello). 2 | -export([hello_world/0]). 3 | 4 | hello_world() -> io:fwrite("hello, world\n"). 5 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func main() { 6 | fmt.Println("Hello, World!") 7 | } 8 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.groovy: -------------------------------------------------------------------------------- 1 | def dummy_function() { 2 | println "Hello world" 3 | } 4 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.gv: -------------------------------------------------------------------------------- 1 | graph ethane { 2 | C_0 -- H_0; 3 | C_0 -- H_1; 4 | C_0 -- H_2; 5 | C_0 -- C_1; 6 | C_1 -- H_3; 7 | C_1 -- H_4; 8 | C_1 -- H_5; 9 | } 10 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.h: -------------------------------------------------------------------------------- 1 | #define SOMETHING 2 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.hcl: -------------------------------------------------------------------------------- 1 | group "default" { 2 | targets = ["build"] 3 | } 4 | 5 | target "build" { 6 | dockerfile = "./Dockerfile" 7 | output = ["type=docker"] 8 | } 9 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.hh: -------------------------------------------------------------------------------- 1 | #define SOMETHING 2 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.hpp: -------------------------------------------------------------------------------- 1 | #define SOMETHING 2 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.hs: -------------------------------------------------------------------------------- 1 | module Main where 2 | 3 | main = putStrLn "Hello, World!" 4 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.html: -------------------------------------------------------------------------------- 1 | 2 |

Hello World!

3 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.java: -------------------------------------------------------------------------------- 1 | public class HelloWorld { 2 | 3 | public static void main(String[] args) { 4 | System.out.println("Hello, World"); 5 | } 6 | 7 | } 8 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.js: -------------------------------------------------------------------------------- 1 | function dummy() { 2 | console.log('hello world!'); 3 | } 4 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.lisp: -------------------------------------------------------------------------------- 1 | ; hello world lisp program. 2 | (print "Hello World") 3 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.m: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | int main (int argc, const char * argv[]) 4 | { 5 | NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; 6 | NSLog (@"Hello, World!"); 7 | [pool drain]; 8 | return 0; 9 | } 10 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.md: -------------------------------------------------------------------------------- 1 | # Markdown 2 | 3 | This is a markdown file and should not be modified. 4 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.mjs: -------------------------------------------------------------------------------- 1 | function dummy() { 2 | console.log('hello world!'); 3 | } 4 | 5 | export default dummy 6 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.mm: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | int main (int argc, const char * argv[]) 4 | { 5 | NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; 6 | NSLog (@"Hello, World!"); 7 | [pool drain]; 8 | return 0; 9 | } 10 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file.php: -------------------------------------------------------------------------------- 1 | 2 | one 3 | 4 | 5 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file1.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | CMD ["echo", "hello world"] 3 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file1.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo hello 3 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file2.Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.3 2 | FROM scratch 3 | CMD ["echo", "hello world"] 4 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file2.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/go run prog.go $* 2 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | one 4 | 5 | 6 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file3.Dockerfile: -------------------------------------------------------------------------------- 1 | # escape=` 2 | FROM microsoft/nanoserver 3 | COPY testfile.txt c:\ 4 | RUN dir c:\ 5 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file_generated.bzl: -------------------------------------------------------------------------------- 1 | """ 2 | cargo-raze crate workspace functions 3 | 4 | DO NOT EDIT! Replaced on runs of cargo-raze 5 | """ 6 | load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") 7 | 8 | def _new_http_archive(name, **kwargs): 9 | if not native.existing_rule(name): 10 | http_archive(name=name, **kwargs) 11 | 12 | def raze_fetch_remote_crates(): 13 | 14 | _new_http_archive( 15 | name = "raze__log__0_4_11", 16 | url = "https://crates-io.s3-us-west-1.amazonaws.com/crates/log/log-0.4.11.crate", 17 | type = "tar.gz", 18 | sha256 = "4fabed175da42fed1fa0746b0ea71f412aa9d35e76e95e59b192c64b9dc2bf8b", 19 | strip_prefix = "log-0.4.11", 20 | build_file = Label("//bazel/cargo/remote:log-0.4.11.BUILD"), 21 | ) 22 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/file_generated.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type foo"; DO NOT EDIT. 2 | 3 | package main 4 | 5 | func (f Foo) String() string { 6 | return "foo" 7 | } 8 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/multiline-comment.sentinel: -------------------------------------------------------------------------------- 1 | /* This policy requires that the `require_lowercase_characters` attribute of the `aws_iam_account_password_policy` 2 | resource is according to CIS standards. */ 3 | 4 | # Imports 5 | 6 | import "tfconfig/v2" as tfconfig 7 | import "tfplan/v2" as tfplan 8 | import "tfresources" as tf 9 | import "report" as report 10 | import "collection" as collection 11 | import "collection/maps" as maps 12 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/multiline-sharp.sentinel: -------------------------------------------------------------------------------- 1 | # This policy requires that the `require_lowercase_characters` attribute of the `aws_iam_account_password_policy` 2 | # resource is according to CIS standards. 3 | # 4 | # another text 5 | 6 | # Imports 7 | 8 | import "tfconfig/v2" as tfconfig 9 | import "tfplan/v2" as tfplan 10 | import "tfresources" as tf 11 | import "report" as report 12 | import "collection" as collection 13 | import "collection/maps" as maps 14 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/multiline-slash.sentinel: -------------------------------------------------------------------------------- 1 | // This policy requires that the `require_lowercase_characters` attribute of the `aws_iam_account_password_policy` 2 | // resource is according to CIS standards. 3 | 4 | # Imports 5 | 6 | import "tfconfig/v2" as tfconfig 7 | import "tfplan/v2" as tfplan 8 | import "tfresources" as tf 9 | import "report" as report 10 | import "collection" as collection 11 | import "collection/maps" as maps 12 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/no-policy.sentinel: -------------------------------------------------------------------------------- 1 | import "tfconfig/v2" as tfconfig 2 | import "tfplan/v2" as tfplan 3 | import "tfresources" as tf 4 | import "report" as report 5 | import "collection" as collection 6 | import "collection/maps" as maps 7 | 8 | # Constants 9 | 10 | const = { 11 | "resource_efs_file_system": "aws_efs_file_system", 12 | "policy_name": "efs-encryption-at-rest-enabled", 13 | "kms_key_id": "kms_key_id", 14 | "constant_value": "constant_value", 15 | "encrypted": "encrypted", 16 | "encrypted_attr_violation_msg": "Attribute 'encrypted' should be true for 'aws_efs_file_system' resources. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/efs-controls.html#efs-1 for more details.", 17 | "kms_key_id_attr_violation_msg": "Attribute 'kms_key_id' should be non empty for 'aws_efs_file_system' resources. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/efs-controls.html#efs-1 for more details.", 18 | } 19 | 20 | # Functions 21 | 22 | build_violation_object = func(res, message) { 23 | return { 24 | "address": res.address, 25 | "module_address": res.module_address, 26 | "message": message, 27 | } 28 | } 29 | 30 | # Variables 31 | 32 | efs_file_systems_from_plan = tf.plan(tfplan.planned_values.resources).type(const.resource_efs_file_system).resources 33 | 34 | # Filter out aws_efs_file_systems that have invalid 'encrypted' attribute 35 | non_encrypted_file_systems = collection.reject(efs_file_systems_from_plan, func(res) { 36 | encrypted_val = maps.get(res, "values.encrypted", false) 37 | return encrypted_val is true 38 | }) 39 | 40 | non_encrypted_file_systems_violations = map non_encrypted_file_systems as _, res { 41 | build_violation_object(res, const.encrypted_attr_violation_msg) 42 | } 43 | 44 | efs_file_systems_from_configs = tf.config(tfconfig.resources).type(const.resource_efs_file_system).resources 45 | 46 | # Filter out aws_efs_file_systems that have empty 'kms_key_id' attribute 47 | efs_resources_with_empty_kms_key_ids = collection.reject(efs_file_systems_from_configs, func(res) { 48 | key_path = "config.kms_key_id" 49 | return maps.get(res, key_path, false) is not false and 50 | maps.get(res, key_path + "." + const.constant_value, false) is not "" 51 | }) 52 | 53 | efs_resources_with_empty_kms_key_ids_violations = map efs_resources_with_empty_kms_key_ids as _, res { 54 | build_violation_object(res, const.kms_key_id_attr_violation_msg) 55 | } 56 | 57 | summary = { 58 | "policy_name": const.policy_name, 59 | "violations": non_encrypted_file_systems_violations + efs_resources_with_empty_kms_key_ids_violations, 60 | } 61 | 62 | # Outputs 63 | 64 | print(report.generate_policy_report(summary)) 65 | 66 | # Rules 67 | 68 | verify_non_encrypted_file_systems = rule { 69 | non_encrypted_file_systems_violations is empty 70 | } 71 | 72 | verify_kms_key_referencing_file_systems = rule { 73 | efs_resources_with_empty_kms_key_ids_violations is empty 74 | } 75 | 76 | main = rule { 77 | verify_non_encrypted_file_systems and verify_kms_key_referencing_file_systems 78 | } 79 | -------------------------------------------------------------------------------- /addlicense/testdata/initial/singleline-slash.sentinel: -------------------------------------------------------------------------------- 1 | // This policy requires that the `require_lowercase_characters` attribute of the `aws_iam_account_password_policy` 2 | 3 | # Imports 4 | 5 | import "tfconfig/v2" as tfconfig 6 | import "tfplan/v2" as tfplan 7 | import "tfresources" as tf 8 | import "report" as report 9 | import "collection" as collection 10 | import "collection/maps" as maps 11 | -------------------------------------------------------------------------------- /addlicense/testdata/multiyear_file.c: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2015-2017,2019 Google LLC All rights reserved. 3 | * Use of this source code is governed by a BSD-style 4 | * license that can be found in the LICENSE file. 5 | */ 6 | 7 | #include 8 | 9 | int main() { 10 | printf("Hello world\n"); 11 | return 0; 12 | } 13 | -------------------------------------------------------------------------------- /addlicense/tmpl.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package addlicense 16 | 17 | import ( 18 | "bufio" 19 | "bytes" 20 | "fmt" 21 | "os" 22 | "strings" 23 | "text/template" 24 | "unicode" 25 | ) 26 | 27 | var licenseTemplate = map[string]string{ 28 | "Apache-2.0": tmplApache, 29 | "MIT": tmplMIT, 30 | "bsd": tmplBSD, 31 | "MPL-2.0": tmplMPL, 32 | } 33 | 34 | // maintain backwards compatibility by mapping legacy license types to their 35 | // SPDX equivalents. 36 | var legacyLicenseTypes = map[string]string{ 37 | "apache": "Apache-2.0", 38 | "mit": "MIT", 39 | "mpl": "MPL-2.0", 40 | } 41 | 42 | // LicenseData specifies the data used to fill out a license template. 43 | type LicenseData struct { 44 | Year string // Copyright year(s). 45 | Holder string // Name of the copyright holder. 46 | SPDXID string // SPDX Identifier 47 | } 48 | 49 | // fetchTemplate returns the license template for the specified license and 50 | // optional templateFile. If templateFile is provided, the license is read 51 | // from the specified file. Otherwise, a template is loaded for the specified 52 | // license, if recognized. 53 | func fetchTemplate(license string, templateFile string, spdx spdxFlag) (string, error) { 54 | var t string 55 | if spdx == spdxOnly { 56 | t = tmplSPDX 57 | } else if templateFile != "" { 58 | d, err := os.ReadFile(templateFile) 59 | if err != nil { 60 | return "", fmt.Errorf("license file: %w", err) 61 | } 62 | 63 | t = string(d) 64 | } else { 65 | t = licenseTemplate[license] 66 | if t == "" { 67 | if spdx == spdxOn { 68 | // unknown license, but SPDX headers requested 69 | t = tmplSPDX 70 | } else { 71 | // unknown license and SPDX headers aren't request, proceed only with 72 | // a copyright header and no license info 73 | t = tmplCopyrightOnly 74 | } 75 | } else if spdx == spdxOn { 76 | // append spdx headers to recognized license 77 | t = t + spdxSuffix 78 | } 79 | } 80 | 81 | return t, nil 82 | } 83 | 84 | // executeTemplate will execute a license template t with data d 85 | // and prefix the result with top, middle and bottom. 86 | func executeTemplate(t *template.Template, d LicenseData, top, mid, bot string) ([]byte, error) { 87 | var buf bytes.Buffer 88 | if err := t.Execute(&buf, d); err != nil { 89 | return nil, err 90 | } 91 | var out bytes.Buffer 92 | if top != "" { 93 | fmt.Fprintln(&out, top) 94 | } 95 | s := bufio.NewScanner(&buf) 96 | for s.Scan() { 97 | fmt.Fprintln(&out, strings.TrimRightFunc(mid+s.Text(), unicode.IsSpace)) 98 | } 99 | if bot != "" { 100 | fmt.Fprintln(&out, bot) 101 | } 102 | fmt.Fprintln(&out) 103 | return out.Bytes(), nil 104 | } 105 | 106 | const tmplApache = `Copyright {{.Year}} {{.Holder}} 107 | 108 | Licensed under the Apache License, Version 2.0 (the "License"); 109 | you may not use this file except in compliance with the License. 110 | You may obtain a copy of the License at 111 | 112 | http://www.apache.org/licenses/LICENSE-2.0 113 | 114 | Unless required by applicable law or agreed to in writing, software 115 | distributed under the License is distributed on an "AS IS" BASIS, 116 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 117 | See the License for the specific language governing permissions and 118 | limitations under the License.` 119 | 120 | const tmplBSD = `Copyright (c) {{.Year}} {{.Holder}} All rights reserved. 121 | Use of this source code is governed by a BSD-style 122 | license that can be found in the LICENSE file.` 123 | 124 | const tmplMIT = `Copyright (c) {{.Year}} {{.Holder}} 125 | 126 | Permission is hereby granted, free of charge, to any person obtaining a copy of 127 | this software and associated documentation files (the "Software"), to deal in 128 | the Software without restriction, including without limitation the rights to 129 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 130 | the Software, and to permit persons to whom the Software is furnished to do so, 131 | subject to the following conditions: 132 | 133 | The above copyright notice and this permission notice shall be included in all 134 | copies or substantial portions of the Software. 135 | 136 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 137 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 138 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 139 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 140 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 141 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.` 142 | 143 | const tmplMPL = `This Source Code Form is subject to the terms of the Mozilla Public 144 | License, v. 2.0. If a copy of the MPL was not distributed with this 145 | file, You can obtain one at https://mozilla.org/MPL/2.0/.` 146 | 147 | const tmplSPDX = `Copyright (c){{ if .Year }} {{.Year}}{{ end }}{{ if .Holder }} {{.Holder}}{{ end }} 148 | {{ if .SPDXID }}SPDX-License-Identifier: {{.SPDXID}}{{ end }}` 149 | 150 | const tmplCopyrightOnly = `Copyright (c){{ if .Year }} {{.Year}}{{ end }}{{ if .Holder }} {{.Holder}}{{ end }}` 151 | 152 | const spdxSuffix = "\n\nSPDX-License-Identifier: {{.SPDXID}}" 153 | -------------------------------------------------------------------------------- /addlicense/tmpl_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package addlicense 16 | 17 | import ( 18 | "errors" 19 | "os" 20 | "testing" 21 | "text/template" 22 | ) 23 | 24 | func init() { 25 | // ensure that pre-defined templates must parse 26 | template.Must(template.New("").Parse(tmplApache)) 27 | template.Must(template.New("").Parse(tmplMIT)) 28 | template.Must(template.New("").Parse(tmplBSD)) 29 | template.Must(template.New("").Parse(tmplMPL)) 30 | } 31 | 32 | func TestFetchTemplate(t *testing.T) { 33 | tests := []struct { 34 | description string // test case description 35 | license string // license passed to fetchTemplate 36 | templateFile string // templatefile passed to fetchTemplate 37 | spdx spdxFlag // spdx value passed to fetchTemplate 38 | wantTemplate string // expected returned template 39 | wantErr error // expected returned error 40 | }{ 41 | // custom template files 42 | { 43 | "non-existent template file", 44 | "", 45 | "/does/not/exist", 46 | spdxOff, 47 | "", 48 | os.ErrNotExist, 49 | }, 50 | { 51 | "custom template file", 52 | "", 53 | "testdata/custom.tpl", 54 | spdxOff, 55 | "Copyright {{.Year}} {{.Holder}}\n\nCustom License Template\n", 56 | nil, 57 | }, 58 | 59 | { 60 | "unknown license", 61 | "unknown", 62 | "", 63 | spdxOff, 64 | tmplCopyrightOnly, 65 | nil, 66 | }, 67 | 68 | // pre-defined license templates, no SPDX 69 | { 70 | "apache license template", 71 | "Apache-2.0", 72 | "", 73 | spdxOff, 74 | tmplApache, 75 | nil, 76 | }, 77 | { 78 | "mit license template", 79 | "MIT", 80 | "", 81 | spdxOff, 82 | tmplMIT, 83 | nil, 84 | }, 85 | { 86 | "bsd license template", 87 | "bsd", 88 | "", 89 | spdxOff, 90 | tmplBSD, 91 | nil, 92 | }, 93 | { 94 | "mpl license template", 95 | "MPL-2.0", 96 | "", 97 | spdxOff, 98 | tmplMPL, 99 | nil, 100 | }, 101 | 102 | // SPDX variants 103 | { 104 | "apache license template with SPDX added", 105 | "Apache-2.0", 106 | "", 107 | spdxOn, 108 | tmplApache + spdxSuffix, 109 | nil, 110 | }, 111 | { 112 | "apache license template with SPDX only", 113 | "Apache-2.0", 114 | "", 115 | spdxOnly, 116 | tmplSPDX, 117 | nil, 118 | }, 119 | { 120 | "unknown license with SPDX only", 121 | "unknown", 122 | "", 123 | spdxOnly, 124 | tmplSPDX, 125 | nil, 126 | }, 127 | } 128 | 129 | for _, tt := range tests { 130 | t.Run(tt.description, func(t *testing.T) { 131 | tpl, err := fetchTemplate(tt.license, tt.templateFile, tt.spdx) 132 | if tt.wantErr != nil && (err == nil || (!errors.Is(err, tt.wantErr) && err.Error() != tt.wantErr.Error())) { 133 | t.Fatalf("fetchTemplate(%q, %q) returned error: %#v, want %#v", tt.license, tt.templateFile, err, tt.wantErr) 134 | } 135 | if tpl != tt.wantTemplate { 136 | t.Errorf("fetchTemplate(%q, %q) returned template: %q, want %q", tt.license, tt.templateFile, tpl, tt.wantTemplate) 137 | } 138 | }) 139 | } 140 | } 141 | 142 | func TestExecuteTemplate(t *testing.T) { 143 | tests := []struct { 144 | template string 145 | data LicenseData 146 | top, mid, bot string 147 | want string 148 | }{ 149 | { 150 | "", 151 | LicenseData{}, 152 | "", "", "", 153 | "\n", 154 | }, 155 | { 156 | "{{.Holder}}{{.Year}}{{.SPDXID}}", 157 | LicenseData{Holder: "H", Year: "Y", SPDXID: "S"}, 158 | "", "", "", 159 | "HYS\n\n", 160 | }, 161 | { 162 | "{{.Holder}}{{.Year}}{{.SPDXID}}", 163 | LicenseData{Holder: "H", Year: "Y", SPDXID: "S"}, 164 | "", "// ", "", 165 | "// HYS\n\n", 166 | }, 167 | { 168 | "{{.Holder}}{{.Year}}{{.SPDXID}}", 169 | LicenseData{Holder: "H", Year: "Y", SPDXID: "S"}, 170 | "/*", " * ", "*/", 171 | "/*\n * HYS\n*/\n\n", 172 | }, 173 | 174 | // ensure we don't escape HTML characters by using the wrong template package 175 | { 176 | "{{.Holder}}", 177 | LicenseData{Holder: "A&Z"}, 178 | "", "", "", 179 | "A&Z\n\n", 180 | }, 181 | } 182 | 183 | for _, tt := range tests { 184 | tpl, err := template.New("").Parse(tt.template) 185 | if err != nil { 186 | t.Errorf("error parsing template: %v", err) 187 | } 188 | got, err := executeTemplate(tpl, tt.data, tt.top, tt.mid, tt.bot) 189 | if err != nil { 190 | t.Errorf("executeTemplate(%q, %v, %q, %q, %q) returned error: %v", tt.template, tt.data, tt.top, tt.mid, tt.bot, err) 191 | } 192 | if string(got) != tt.want { 193 | t.Errorf("executeTemplate(%q, %v, %q, %q, %q) returned %q, want: %q", tt.template, tt.data, tt.top, tt.mid, tt.bot, string(got), tt.want) 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /cmd/debug.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package cmd 5 | 6 | import ( 7 | "context" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/hashicorp/copywrite/github" 12 | "github.com/hashicorp/go-hclog" 13 | "github.com/jedib0t/go-pretty/v6/text" 14 | "github.com/mergestat/timediff" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | // debugCmd represents the debug command 19 | var debugCmd = &cobra.Command{ 20 | Use: "debug", 21 | Short: "Prints env-specific debug information about copywrite", 22 | Long: `Prints information to help debug issues, including: 23 | - Copywrite Version 24 | - Running configuration 25 | - Current GitHub repo (if one is detected) 26 | - GitHub authentication status`, 27 | PreRun: func(cmd *cobra.Command, args []string) { 28 | // Change directory if needed 29 | if dirPath != "." { 30 | err := os.Chdir(dirPath) 31 | cobra.CheckErr(err) 32 | } 33 | 34 | // Let's forcibly enable trace-level logging 35 | cliLogger.SetLevel(hclog.Trace) 36 | }, 37 | Run: func(cmd *cobra.Command, args []string) { 38 | title := func(t string) { 39 | escaped := colorize(t, text.FgCyan, text.Bold) 40 | cmd.Println(escaped) 41 | } 42 | 43 | // 44 | // Print version info 45 | // 46 | title("Copywrite Version:") 47 | version := GetVersion() 48 | cmd.Printf("%v\n\n", version) 49 | 50 | // 51 | // Print working directory info 52 | // 53 | title("Working Directory:") 54 | if dirPath != "." { 55 | cmd.Print("The working directory was overwritten with the --dirPath flag\n") 56 | } 57 | absDirPath, _ := filepath.Abs(dirPath) 58 | cmd.Printf("Directory path: %v\n\n", absDirPath) 59 | 60 | // 61 | // Print info relating to any configuration file found 62 | // 63 | title("Copywrite Configuration File:") 64 | path := conf.GetConfigPath() 65 | cmd.Printf("Configuration file path: %s\n", path) 66 | if _, err := os.Stat(path); err == nil { 67 | cmd.Print("✔️ Config file exists\n\n") 68 | } else { 69 | cmd.Print("❌ File does not exist\n\n") 70 | } 71 | 72 | // 73 | // Print running config 74 | // 75 | title("Running Config:") 76 | runningConfigString := conf.Sprint() 77 | cmd.Printf("%v\n", runningConfigString) 78 | 79 | // 80 | // Print GitHub Actions/CI Information 81 | // 82 | title("GitHub Actions:") 83 | if gha.IsGHA() { 84 | cmd.Print("Current execution environment is GitHub Actions\n\n") 85 | } else { 86 | cmd.Print("Current execution environment is NOT GitHub Actions\n\n") 87 | } 88 | 89 | // 90 | // Print any GitHub repo that is discovered 91 | // 92 | title("Current GitHub Repo:") 93 | repo, err := github.DiscoverRepo() 94 | if err != nil { 95 | cmd.Println(err) 96 | } else { 97 | cmd.Printf("GitHub Org:\t%v\n", repo.Owner) 98 | cmd.Printf("GitHub Repo:\t%v\n", repo.Name) 99 | } 100 | cmd.Println() 101 | 102 | // 103 | // Attempt to auth to GitHub and print any relevant info 104 | // 105 | title("Attempting GitHub Authentication:") 106 | ghc := github.NewGHClient().Raw() 107 | 108 | user, _, _ := ghc.Users.Get(context.Background(), "") 109 | cmd.Printf("Running as authenticated user: %v (@%v)\n", user.GetName(), user.GetLogin()) 110 | 111 | rateLimits, _, _ := ghc.RateLimits(context.Background()) 112 | cmd.Printf("GitHub API rate limits: %v/%v remaining\n", rateLimits.Core.Remaining, rateLimits.Core.Limit) 113 | cmd.Printf("GitHub API rate limits will reset at: %v (%v)\n", rateLimits.Core.Reset, timediff.TimeDiff(rateLimits.Core.Reset.Time)) 114 | }, 115 | } 116 | 117 | func init() { 118 | rootCmd.AddCommand(debugCmd) 119 | 120 | // These flags are only locally relevant 121 | debugCmd.Flags().StringVarP(&dirPath, "dirPath", "d", ".", "Path to the directory in which you wish to introspect") 122 | } 123 | -------------------------------------------------------------------------------- /cmd/dispatch.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/google/go-github/v45/github" 10 | "github.com/hashicorp/copywrite/dispatch" 11 | gh "github.com/hashicorp/copywrite/github" 12 | "github.com/hashicorp/copywrite/repodata" 13 | "github.com/jedib0t/go-pretty/v6/text" 14 | "github.com/samber/lo" 15 | "github.com/spf13/cobra" 16 | "github.com/thanhpk/randstr" 17 | ) 18 | 19 | var dispatchCmd = &cobra.Command{ 20 | Use: "dispatch", 21 | Short: "Dispatches audit jobs for a list of repos", 22 | Long: `Dispatches audit jobs for all public and non-archived repos`, 23 | PreRun: func(cmd *cobra.Command, args []string) { 24 | // Map command flags to config keys 25 | mapping := map[string]string{ 26 | `batch-id`: `dispatch.batch_id`, 27 | `branch`: `dispatch.branch`, 28 | `max-attempts`: `dispatch.max_attempts`, 29 | `sleep`: `dispatch.sleep`, 30 | `workers`: `dispatch.workers`, 31 | `workflow`: `dispatch.workflow_file_name`, 32 | `github-org`: `dispatch.github_org_to_audit`, 33 | } 34 | 35 | // update the running config with any command-line flags 36 | clobberWithDefaults := false 37 | err := conf.LoadCommandFlags(cmd.Flags(), mapping, clobberWithDefaults) 38 | if err != nil { 39 | cliLogger.Error("Error merging configuration", err) 40 | } 41 | cobra.CheckErr(err) 42 | 43 | // Dynamically generate a batchID if none is supplied 44 | if conf.Dispatch.BatchID == "" { 45 | conf.Dispatch.BatchID = randstr.Hex(8) // 8-digit random string 46 | cliLogger.Debug(fmt.Sprintf("Using auto-generated batchID: %s", conf.Dispatch.BatchID)) 47 | } 48 | }, 49 | Run: func(cmd *cobra.Command, args []string) { 50 | 51 | client := gh.NewGHClient().Raw() 52 | 53 | // Retrieve all public, non-archived GitHub repos for auditing 54 | allRepos, err := repodata.GetRepos(conf.Dispatch.GitHubOrgToAudit) 55 | cobra.CheckErr(err) 56 | 57 | targetRepos := repodata.FilterRepos(allRepos) 58 | 59 | if len(conf.Dispatch.IgnoredRepos) > 0 { 60 | gha.StartGroup("Exempting the following repos:") 61 | for _, v := range conf.Dispatch.IgnoredRepos { 62 | cliLogger.Info(text.FgCyan.Sprint(v)) 63 | } 64 | gha.EndGroup() 65 | 66 | // Filter out any repos that are on the ignore list 67 | targetRepos = lo.Filter(targetRepos, func(r *github.Repository, i int) bool { 68 | fqn := fmt.Sprintf("%v/%v", conf.Dispatch.GitHubOrgToAudit, r.GetName()) 69 | return !lo.Contains(conf.Dispatch.IgnoredRepos, fqn) 70 | }) 71 | } 72 | 73 | cliLogger.Info(fmt.Sprintf("Repositories will be audited with the \"%v\" GitHub Actions workflow", conf.Dispatch.WorkflowFileName)) 74 | cliLogger.Info(fmt.Sprintf("Set to process %v GitHub repositories with %v concurrent workers", len(targetRepos), conf.Dispatch.Workers)) 75 | 76 | if plan { 77 | cliLogger.Info(text.Bold.Sprint("The following repos would be audited:")) 78 | for _, v := range targetRepos { 79 | cliLogger.Info(fmt.Sprintf("%v/%v", conf.Dispatch.GitHubOrgToAudit, *v.Name)) 80 | } 81 | cliLogger.Info(text.FgYellow.Sprintf("Executing in dry-run mode. Rerun without the `--plan` flag to trigger audits on all %v repos.", len(targetRepos))) 82 | return 83 | } 84 | 85 | // The actual stuff 86 | 87 | repo, err := gh.DiscoverRepo() 88 | cobra.CheckErr(err) 89 | 90 | opts := dispatch.Options{ 91 | SecondsBetweenPolls: conf.Dispatch.Sleep, 92 | MaxAttempts: conf.Dispatch.MaxAttempts, 93 | Logger: cliLogger.Named("dispatch"), 94 | BranchRef: conf.Dispatch.Branch, 95 | BatchID: conf.Dispatch.BatchID, 96 | WorkflowFileName: conf.Dispatch.WorkflowFileName, 97 | GitHubOwner: repo.Owner, 98 | GitHubRepo: repo.Name, 99 | } 100 | 101 | numJobs := len(targetRepos) 102 | jobs := make(chan string, numJobs) 103 | results := make(chan dispatch.Result, numJobs) 104 | 105 | // Create a worker pool 106 | for w := 1; w <= conf.Dispatch.Workers; w++ { 107 | go dispatch.Worker(client, opts, w, jobs, results) 108 | } 109 | 110 | // Queue up all of the repos to be processed by the worker pool 111 | for _, v := range targetRepos { 112 | jobs <- *v.Name 113 | } 114 | 115 | // TODO: the 'jobs' channel will need to remain open if we decide to requeue 116 | // failed jobs in the future. 117 | close(jobs) 118 | 119 | // Let's print out any failure cases 120 | failures := []dispatch.Result{} 121 | for a := 1; a <= numJobs; a++ { 122 | result := <-results 123 | if !result.Success { 124 | failures = append(failures, result) 125 | } 126 | } 127 | 128 | if len(failures) > 0 { 129 | cliLogger.Error(fmt.Sprintf("Job failures occurred %d times:", len(failures))) 130 | for _, f := range failures { 131 | cliLogger.Error(fmt.Sprint(f)) 132 | } 133 | } 134 | 135 | }, 136 | } 137 | 138 | func init() { 139 | rootCmd.AddCommand(dispatchCmd) 140 | 141 | // These flags are only locally relevant 142 | dispatchCmd.Flags().BoolVar(&plan, "plan", false, "Performs a dry-run, printing the names of all repos that would be audited") 143 | dispatchCmd.Flags().Int("max-attempts", 15, "Number of times a worker will check if a job has completed before timing out") 144 | dispatchCmd.Flags().IntP("sleep", "s", 10, "Seconds to sleep between polling opts") 145 | dispatchCmd.Flags().IntP("workers", "w", 2, "Concurrent jobs that can be ran") 146 | dispatchCmd.Flags().StringP("branch", "b", "main", "The GitHub Branch to base workflow runs off of") 147 | dispatchCmd.Flags().StringP("batch-id", "i", "", "A unique identifier for the current batch of workflow runs (defaults to an autogenerated ULID)") 148 | dispatchCmd.Flags().StringP("workflow", "n", "repair-repo-license.yml", "The workflow file name to be triggered") 149 | dispatchCmd.Flags().String("github-org", "hashicorp", "Sets the target GitHub org who's repos you wish to audit") 150 | } 151 | -------------------------------------------------------------------------------- /cmd/headers.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | 10 | "github.com/hashicorp/copywrite/addlicense" 11 | "github.com/hashicorp/go-hclog" 12 | "github.com/jedib0t/go-pretty/v6/text" 13 | "github.com/samber/lo" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | // Flag variables 18 | var ( 19 | plan bool 20 | ) 21 | 22 | var headersCmd = &cobra.Command{ 23 | Use: "headers", 24 | Short: "Adds missing copyright headers to all source code files", 25 | Long: `Recursively checks for all files in the given directory and subdirectories, 26 | adding copyright statements and license headers to any that are missing them. 27 | 28 | Autogenerated files and common file types that don't support headers (e.g., prose) 29 | will automatically be exempted. Any other files or folders should be added to the 30 | header_ignore list in your project's .copywrite.hcl config. For help adding a 31 | config, see the "copywrite init" command.`, 32 | GroupID: "common", // Let's put this command in the common section of the help 33 | PreRun: func(cmd *cobra.Command, args []string) { 34 | // Change directory if needed 35 | if dirPath != "." { 36 | err := os.Chdir(dirPath) 37 | cobra.CheckErr(err) 38 | } 39 | 40 | // Map command flags to config keys 41 | mapping := map[string]string{ 42 | `spdx`: `project.license`, 43 | `copyright-holder`: `project.copyright_holder`, 44 | } 45 | 46 | // update the running config with any command-line flags 47 | clobberWithDefaults := false 48 | err := conf.LoadCommandFlags(cmd.Flags(), mapping, clobberWithDefaults) 49 | if err != nil { 50 | cliLogger.Error("Error merging configuration", err) 51 | } 52 | cobra.CheckErr(err) 53 | 54 | // Input Validation 55 | isValidSPDX := addlicense.ValidSPDX(conf.Project.License) 56 | if conf.Project.License != "" && !isValidSPDX { 57 | err := fmt.Errorf("invalid SPDX license identifier: %s", conf.Project.License) 58 | cliLogger.Error("Error validating SPDX license", err) 59 | cobra.CheckErr(err) 60 | } 61 | }, 62 | Run: func(cmd *cobra.Command, args []string) { 63 | if plan { 64 | cmd.Print(text.FgYellow.Sprint("Executing in dry-run mode. Rerun without the `--plan` flag to apply changes.\n\n")) 65 | } 66 | 67 | if conf.Project.License == "" { 68 | cmd.Printf("The --spdx flag was not specified, omitting SPDX license statements.\n\n") 69 | } else { 70 | cmd.Printf("Using license identifier: %s\n", conf.Project.License) 71 | } 72 | cmd.Printf("Using copyright holder: %v\n\n", conf.Project.CopyrightHolder) 73 | 74 | if len(conf.Project.HeaderIgnore) == 0 { 75 | cmd.Println("The project.header_ignore list was left empty in config. Processing all files by default.") 76 | } else { 77 | gha.StartGroup("Exempting the following search patterns:") 78 | for _, v := range conf.Project.HeaderIgnore { 79 | cmd.Println(text.FgCyan.Sprint(v)) 80 | } 81 | gha.EndGroup() 82 | } 83 | cmd.Println("") 84 | 85 | // Append default ignored search patterns (e.g., GitHub Actions workflows) 86 | autoSkippedPatterns := []string{ 87 | ".github/workflows/**", 88 | ".github/dependabot.yml", 89 | "**/node_modules/**", 90 | } 91 | ignoredPatterns := lo.Union(conf.Project.HeaderIgnore, autoSkippedPatterns) 92 | 93 | // Construct the configuration addLicense needs to properly format headers 94 | licenseData := addlicense.LicenseData{ 95 | Year: "", // by default, we don't include a year in copyright statements 96 | Holder: conf.Project.CopyrightHolder, 97 | SPDXID: conf.Project.License, 98 | } 99 | 100 | verbose := true 101 | 102 | // Wrap hclogger to use standard lib's log.Logger 103 | stdcliLogger := cliLogger.StandardLogger(&hclog.StandardLoggerOptions{ 104 | // InferLevels must be true so that addLicense can set the log level via 105 | // log prefix, e.g. logger.Println("[DEBUG] this is inferred as a debug log") 106 | InferLevels: true, 107 | }) 108 | 109 | // WARNING: because of the way we redirect cliLogger to os.Stdout, anything 110 | // prefixed with "[ERROR]" will not implicitly be written to stderr. 111 | // However, we propagate errors upward from addlicense and then run a 112 | // cobra.CheckErr on the return, which will indeed output to stderr and 113 | // return a non-zero error code. 114 | 115 | gha.StartGroup("The following files are missing headers:") 116 | err := addlicense.Run(ignoredPatterns, "only", licenseData, "", verbose, plan, []string{"."}, stdcliLogger) 117 | gha.EndGroup() 118 | 119 | cobra.CheckErr(err) 120 | }, 121 | } 122 | 123 | func init() { 124 | rootCmd.AddCommand(headersCmd) 125 | 126 | // These flags are only locally relevant 127 | headersCmd.Flags().StringVarP(&dirPath, "dirPath", "d", ".", "Path to the directory in which you wish to validate headers") 128 | headersCmd.Flags().BoolVar(&plan, "plan", false, "Performs a dry-run, printing the names of all files missing headers") 129 | 130 | // These flags will get mapped to keys in the the global Config 131 | headersCmd.Flags().StringP("spdx", "s", "", "SPDX-compliant license identifier (e.g., 'MPL-2.0')") 132 | headersCmd.Flags().StringP("copyright-holder", "c", "", "Copyright holder (default \"HashiCorp, Inc.\")") 133 | } 134 | -------------------------------------------------------------------------------- /cmd/init.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package cmd 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "os" 12 | "strconv" 13 | "strings" 14 | "text/template" 15 | "time" 16 | 17 | "github.com/hashicorp/copywrite/addlicense" 18 | "github.com/hashicorp/copywrite/config" 19 | "github.com/hashicorp/copywrite/github" 20 | "github.com/jedib0t/go-pretty/v6/text" 21 | "github.com/mattn/go-isatty" 22 | "github.com/samber/lo" 23 | 24 | "github.com/AlecAivazis/survey/v2" 25 | "github.com/spf13/cobra" 26 | ) 27 | 28 | var ( 29 | force bool 30 | ) 31 | 32 | var initCmd = &cobra.Command{ 33 | Use: "init", 34 | Short: "Generates a .copywrite.hcl config for a new project", 35 | Long: `Generates a .copywrite.hcl config for a new project with helpful comments. 36 | 37 | License type and copyright year are inferred from GitHub, and prompts are made 38 | for any unknown values. If you are running this command in CI, please use the 39 | --year and --spdx flags, as prompts are disabled when no TTY is present.`, 40 | GroupID: "common", // Let's put this command in the common section of the help 41 | PreRun: func(cmd *cobra.Command, args []string) { 42 | // Validate we aren't going to write over an existing config 43 | _, err := os.Stat(".copywrite.hcl") 44 | if !errors.Is(err, os.ErrNotExist) && !force { 45 | cobra.CheckErr(fmt.Errorf(".copywrite.hcl config already exists. If you wish to override it, use the `--force` flag")) 46 | } 47 | 48 | // Input Validation 49 | spdx, err := cmd.Flags().GetString("spdx") 50 | cobra.CheckErr(err) 51 | // SPDX flag must either be an empty string _or_ a valid SPDX list option 52 | if spdx != "" && !addlicense.ValidSPDX(spdx) { 53 | err := fmt.Errorf("invalid SPDX license identifier: %s", spdx) 54 | cobra.CheckErr(err) 55 | } 56 | }, 57 | Run: func(cmd *cobra.Command, args []string) { 58 | // We create a new config object here to ensure any existing 59 | // .copywrite.hcl does not influence the new configuration file 60 | newConfig, err := config.New() 61 | cobra.CheckErr(err) 62 | 63 | // Map command flags to config keys 64 | mapping := map[string]string{ 65 | `spdx`: `project.license`, 66 | `year`: `project.copyright_year`, 67 | } 68 | 69 | // update the running config with any command-line flags 70 | clobberWithDefaults := false 71 | err = newConfig.LoadCommandFlags(cmd.Flags(), mapping, clobberWithDefaults) 72 | cobra.CheckErr(err) 73 | 74 | // Try to autodiscover license and year 75 | if repo, err := github.DiscoverRepo(); err == nil { 76 | client := github.NewGHClient().Raw() 77 | data, _, err := client.Repositories.Get(context.Background(), repo.Owner, repo.Name) 78 | if err == nil { 79 | cobra.CheckErr(err) 80 | // fall back to GitHub repo creation year if --year wasn't set 81 | if !cmd.Flags().Changed("year") { 82 | newConfig.Project.CopyrightYear = data.CreatedAt.Year() 83 | } 84 | 85 | // fall back to GitHub's reported SPDX identifier if --spdx wasn't set 86 | if !cmd.Flags().Changed("spdx") { 87 | license := data.GetLicense() 88 | newConfig.Project.License = license.GetSPDXID() 89 | } 90 | } 91 | } 92 | 93 | // Let's prompt the user to validate the current values 94 | if cmd.OutOrStdout() == os.Stdout && isatty.IsTerminal(os.Stdout.Fd()) { 95 | err = promptForConfigValues(newConfig) 96 | cobra.CheckErr(err) 97 | } else { 98 | cmd.Println("No TTY detected: if running in CI, use `--year` and `--spdx` flags to set values as needed") 99 | } 100 | 101 | // Render it out! 102 | f, err := os.Create(".copywrite.hcl") 103 | cobra.CheckErr(err) 104 | defer f.Close() 105 | 106 | err = configToHCL(*newConfig, f) 107 | cobra.CheckErr(err) 108 | 109 | successText := text.Color(text.FgGreen).Sprintf("✔️ A config has been successfully generated at: ./%s", f.Name()) 110 | cmd.Println(successText) 111 | cmd.Println("Please commit this file to your repo") 112 | }, 113 | } 114 | 115 | func init() { 116 | rootCmd.AddCommand(initCmd) 117 | 118 | initCmd.Flags().BoolVarP(&force, "force", "f", false, "Overwrite an existing .copywrite.hcl file, if one exists") 119 | 120 | // These flags will get mapped to keys in the the global Config 121 | initCmd.Flags().IntP("year", "y", 0, "Year that the copyright statement should include") 122 | initCmd.Flags().StringP("spdx", "s", "", "SPDX License Identifier indicating what the project should be licensed under") 123 | } 124 | 125 | // configToHCL takes in a Config object and writes an example HCL configuration, 126 | // filling in the `project.license` and `project.copyright_year` keys, along 127 | // with helpful comments. Any io.Writer interface is accepted, be it stdout 128 | // or a file writer. 129 | // 130 | // Config keys other than license and copyright year are currently unsupported. 131 | func configToHCL(c config.Config, wr io.Writer) error { 132 | tmpl, err := template.New(".copywrite.hcl").Parse(`schema_version = {{.SchemaVersion}} 133 | 134 | project { 135 | license = "{{.Project.License}}" 136 | copyright_year = {{.Project.CopyrightYear}} 137 | 138 | # (OPTIONAL) A list of globs that should not have copyright/license headers. 139 | # Supports doublestar glob patterns for more flexibility in defining which 140 | # files or folders should be ignored 141 | header_ignore = [ 142 | # "vendor/**", 143 | # "**autogen**", 144 | ] 145 | } 146 | `) 147 | if err != nil { 148 | return err 149 | } 150 | 151 | err = tmpl.Execute(wr, c) 152 | if err != nil { 153 | return err 154 | } 155 | 156 | return nil 157 | } 158 | 159 | // promptForConfigValues takes in a pointer to a Config object and prompts the 160 | // user to select or confirm selections for project license type (SPDX ID) and 161 | // copyright year, which then get written back to the config object. 162 | func promptForConfigValues(c *config.Config) error { 163 | noLicenseText := "" // Copywrite uses an empty string to represent no license 164 | 165 | currentLicense := strings.ToUpper(c.Project.License) 166 | licenseOptions := lo.Uniq([]string{noLicenseText, currentLicense, "MPL-2.0", "MIT", "Apache-2.0"}) 167 | 168 | prompts := []*survey.Question{ 169 | { 170 | Name: "License", 171 | Prompt: &survey.Select{ 172 | Message: "Choose a license:", 173 | Options: licenseOptions, 174 | Default: currentLicense, // default to using the current license 175 | Help: "HashiCorp defaults to using MPL-2.0 for public projects", 176 | Description: func(value string, index int) string { 177 | switch value { 178 | case noLicenseText: 179 | return "Proceed without a license" 180 | // Current repo license is before MPL-2.0 intentionally for UX clarity 181 | case c.Project.License: 182 | return "Current Repo License" 183 | case "MPL-2.0": 184 | return "HashiCorp default for public repos" 185 | default: 186 | return "" 187 | } 188 | }, 189 | }, 190 | }, 191 | { 192 | Name: "CopyrightYear", 193 | Prompt: &survey.Input{ 194 | Message: "Choose a copyright year:", 195 | Default: strconv.Itoa(c.Project.CopyrightYear), 196 | Help: "HashiCorp defaults to the earlier of the repo creation year or when the project was first published", 197 | }, 198 | Validate: func(val interface{}) error { 199 | i, err := strconv.Atoi(val.(string)) 200 | if err != nil { 201 | return fmt.Errorf("year must be a number") 202 | } 203 | 204 | // Let's do some minor sanity checking here 205 | minYear := 1970 206 | maxYear := time.Now().Year() + 1 207 | if i < minYear || i > maxYear { 208 | return fmt.Errorf("copyright year is expected to be between %v and %v", minYear, maxYear) 209 | } 210 | 211 | return nil 212 | }, 213 | }, 214 | } 215 | 216 | answers := struct { 217 | License string `survey:"License"` 218 | CopyrightYear int `survey:"CopyrightYear"` 219 | }{} 220 | 221 | // prompt the user 222 | err := survey.Ask(prompts, &answers) 223 | if err != nil { 224 | return err 225 | } 226 | 227 | c.Project.License = answers.License 228 | c.Project.CopyrightYear = answers.CopyrightYear 229 | 230 | return nil 231 | } 232 | -------------------------------------------------------------------------------- /cmd/license.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package cmd 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "path/filepath" 10 | "strconv" 11 | 12 | "github.com/hashicorp/copywrite/github" 13 | "github.com/hashicorp/copywrite/licensecheck" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | // Flag variables 18 | var ( 19 | dirPath string 20 | ) 21 | 22 | // licenseCmd represents the license command 23 | var licenseCmd = &cobra.Command{ 24 | Use: "license", 25 | Short: "Validates that a LICENSE file is present and remediates any issues if found", 26 | Long: `Validates that a LICENSE file is present and remediates any issues if found: 27 | - Check if any files appear to be licenses 28 | - If no files are found, a license will be added 29 | - If a file is found but it does not adhere to the "LICENSE" desired nomenclature, it will be renamed 30 | - If a file is found that matches the desired naming scheme, it is left alone 31 | - If multiple files are found, an error will be returned`, 32 | GroupID: "common", // Let's put this command in the common section of the help 33 | PreRun: func(cmd *cobra.Command, args []string) { 34 | // Map command flags to config keys 35 | mapping := map[string]string{ 36 | `spdx`: `project.license`, 37 | `year`: `project.copyright_year`, 38 | `copyright-holder`: `project.copyright_holder`, 39 | } 40 | 41 | // update the running config with any command-line flags 42 | clobberWithDefaults := false 43 | err := conf.LoadCommandFlags(cmd.Flags(), mapping, clobberWithDefaults) 44 | cobra.CheckErr(err) 45 | 46 | // Input Validation 47 | if conf.Project.CopyrightYear == 0 { 48 | errYearNotFound := errors.New("Unable to automatically determine copyright year. Please specify it manually in the config or via the --year flag") 49 | 50 | cliLogger.Info("Copyright year was not supplied via config or via the --year flag. Attempting to infer from the year the GitHub repo was created.") 51 | repo, err := github.DiscoverRepo() 52 | if err != nil { 53 | cobra.CheckErr(fmt.Errorf("%v: %w", errYearNotFound, err)) 54 | } 55 | 56 | client := github.NewGHClient().Raw() 57 | year, err := github.GetRepoCreationYear(client, repo) 58 | if err != nil { 59 | cobra.CheckErr(fmt.Errorf("%v: %w", errYearNotFound, err)) 60 | } 61 | conf.Project.CopyrightYear = year 62 | } 63 | }, 64 | Run: func(cmd *cobra.Command, args []string) { 65 | 66 | cmd.Printf("Licensing under the following terms: %s\n", conf.Project.License) 67 | cmd.Printf("Using year of initial copyright: %v\n", conf.Project.CopyrightYear) 68 | cmd.Printf("Using copyright holder: %v\n\n", conf.Project.CopyrightHolder) 69 | 70 | copyright := "Copyright (c) " + strconv.Itoa(conf.Project.CopyrightYear) + " " + conf.Project.CopyrightHolder 71 | 72 | licenseFiles, err := licensecheck.FindLicenseFiles(dirPath) 73 | if err != nil { 74 | cliLogger.Error("Error when discovering license files", err) 75 | } 76 | cobra.CheckErr(err) 77 | 78 | var file string 79 | 80 | if len(licenseFiles) > 1 { 81 | err = fmt.Errorf("More than one license file exists. Please review the following files and manually ensure only one is present: %s", licenseFiles) 82 | cliLogger.Error(err.Error()) 83 | cobra.CheckErr(err) 84 | return 85 | } 86 | 87 | if len(licenseFiles) == 0 { 88 | if plan { 89 | cobra.CheckErr("missing license file. Run without the --plan flag to fix this") 90 | } 91 | 92 | cmd.Println("No license file found, creating one.") 93 | path, err := licensecheck.AddLicenseFile(dirPath, conf.Project.License) 94 | if err != nil { 95 | cliLogger.Error("Error adding new license file", err) 96 | } 97 | cobra.CheckErr(err) 98 | file = path 99 | } 100 | 101 | if len(licenseFiles) == 1 { 102 | file = licenseFiles[0] 103 | } 104 | 105 | // Only a single license file is present beyond this point 106 | 107 | // Let's make sure the license file adheres to our naming standard 108 | if plan { 109 | dir, _ := filepath.Split(file) 110 | desiredPath := filepath.Join(dir, "LICENSE") 111 | if file != desiredPath { 112 | err := fmt.Errorf("license file is misnamed. Run without the --plan flag to fix this") 113 | cliLogger.Error(err.Error()) 114 | cobra.CheckErr(err) 115 | } else { 116 | cmd.Println("License file is present and named properly!") 117 | } 118 | } else { 119 | file, err = licensecheck.EnsureCorrectName(file) 120 | if err != nil { 121 | cliLogger.Error("Problem correcting LICENSE filename", err) 122 | } 123 | cobra.CheckErr(err) 124 | } 125 | 126 | // TODO: make sure the LICENSE file contains the appropriate license text 127 | 128 | // Let's make sure it has a valid copyright header, too 129 | cmd.Println("Validating presence of license header") 130 | 131 | hasCopyright, err := licensecheck.HasCopyright(file) 132 | if err != nil { 133 | cliLogger.Error("Problem verifying a copyright statement", err) 134 | } 135 | cobra.CheckErr(err) 136 | 137 | hasValidCopyright, err := licensecheck.HasMatchingCopyright(file, copyright, true) 138 | if err != nil { 139 | cliLogger.Error("Problem matching copyright", err) 140 | } 141 | cobra.CheckErr(err) 142 | 143 | if hasCopyright { 144 | if hasValidCopyright { 145 | cmd.Println("Copyright statement is valid!") 146 | } else { 147 | err = fmt.Errorf("license file has a copyright statement, but it is malformed; Expected to find: \"%s\" Please resolve this manually", copyright) 148 | cliLogger.Error(err.Error()) 149 | cobra.CheckErr(err) 150 | } 151 | } else { 152 | if plan { 153 | cobra.CheckErr("a LICENSE file exists, but the copyright statement is missing. Run without the --plan flag to fix this") 154 | } 155 | 156 | cmd.Println("Copyright statement is missing... attempting to add it") 157 | err = licensecheck.AddHeader(file, copyright) 158 | if err != nil { 159 | cliLogger.Error("Error adding header", err) 160 | } 161 | cobra.CheckErr(err) 162 | } 163 | }, 164 | } 165 | 166 | func init() { 167 | rootCmd.AddCommand(licenseCmd) 168 | 169 | // These flags are only locally relevant 170 | licenseCmd.Flags().StringVarP(&dirPath, "dirPath", "d", ".", "Path to the directory in which you wish to validate a LICENSE file in") 171 | licenseCmd.Flags().BoolVar(&plan, "plan", false, "Performs a dry-run and gives a non-zero return if improperly licensed") 172 | 173 | // These flags will get mapped to keys in the the global Config 174 | // TODO: eventually, the copyrightYear should be dynamically inferred from the repo 175 | licenseCmd.Flags().IntP("year", "y", 0, "Year that the copyright statement should include") 176 | licenseCmd.Flags().StringP("spdx", "s", "", "SPDX License Identifier indicating what the LICENSE file should represent") 177 | licenseCmd.Flags().StringP("copyright-holder", "c", "", "Copyright holder (default \"HashiCorp, Inc.\")") 178 | } 179 | -------------------------------------------------------------------------------- /cmd/report.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package cmd 5 | 6 | import ( 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var csv bool 11 | 12 | var reportCmd = &cobra.Command{ 13 | Use: "report", 14 | Short: "Performs a variety of reporting tasks", 15 | Long: `Use the audit subcommands to retrieve reports such as unlicensed repos, outstanding pull requests, and more`, 16 | // Run function is omitted, as this command exists only to house subcommands 17 | } 18 | 19 | func init() { 20 | rootCmd.AddCommand(reportCmd) 21 | } 22 | -------------------------------------------------------------------------------- /cmd/report_prs.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package cmd 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "strings" 10 | 11 | "github.com/google/go-github/v45/github" 12 | gh "github.com/hashicorp/copywrite/github" 13 | "github.com/mergestat/timediff" 14 | 15 | "github.com/jedib0t/go-pretty/v6/table" 16 | "github.com/jedib0t/go-pretty/v6/text" 17 | "github.com/spf13/cobra" 18 | ) 19 | 20 | var ( 21 | author string 22 | status string 23 | ) 24 | 25 | var reportPRsCmd = &cobra.Command{ 26 | Use: "prs", 27 | Short: "Lists all unmerged compliance pull requests", 28 | Long: `Lists all unmerged compliance pull requests 29 | 30 | By default, PRs are found by searching by author. Any PRs created by the 31 | copyright notice automation tooling will be authored by "hashicorp-copywrite"`, 32 | Run: func(cmd *cobra.Command, args []string) { 33 | // Disable color pretty-print if not intended for human eyes 34 | if csv { 35 | text.DisableColors() 36 | } 37 | 38 | client := gh.NewGHClient().Raw() 39 | 40 | opt := &github.SearchOptions{ 41 | ListOptions: github.ListOptions{PerPage: 100}, // 100 is the max page size 42 | Sort: "created", 43 | Order: "asc", 44 | } 45 | 46 | query := fmt.Sprintf("is:pr author:%s", author) 47 | 48 | // validate status flag and append to query if needed 49 | switch status { 50 | case "open", "closed": 51 | query = query + fmt.Sprintf(" is:%s", status) 52 | case "all": 53 | // Do nothing, omitting a filter defaults to "all" 54 | default: 55 | err := fmt.Sprintf("Invalid argument \"%s\" for \"--status\" flag. Valid options are: open|closed|all", status) 56 | cliLogger.Error(err) 57 | cobra.CheckErr(err) 58 | 59 | } 60 | 61 | // pagination to retrieve all issues 62 | var prs []github.Issue 63 | for { 64 | page, current, err := client.Search.Issues(context.Background(), query, opt) 65 | 66 | // TODO: retry and gracefully degrade 67 | cobra.CheckErr(err) 68 | 69 | for _, issue := range page.Issues { 70 | prs = append(prs, *issue) 71 | } 72 | 73 | // check if no more pages before continuing pagination 74 | if current.NextPage == 0 { 75 | break 76 | } 77 | opt.Page = current.NextPage 78 | } 79 | 80 | // Let's turn this into some tabular data and render it out 81 | 82 | t := newTableWriter(cmd.OutOrStdout()) 83 | t.AppendHeader(table.Row{"Pull Request", "Name", "Age", "Link"}) 84 | 85 | for _, i := range prs { 86 | // The repo name is not a field on Issues, so we have to infer by 87 | // extracting from the RepositoryURL string 88 | s := strings.SplitAfter(*i.RepositoryURL, "https://api.github.com/repos/") 89 | repoName := s[len(s)-1] 90 | 91 | // let's format the pull request reference as "org/repo#number" 92 | prRef := text.FgCyan.Sprint(repoName + "#" + fmt.Sprint(*i.Number)) 93 | 94 | // get a human-friendly age string (e.g., "1 month ago") 95 | age := timediff.TimeDiff(*i.CreatedAt) 96 | 97 | t.AppendRow(table.Row{prRef, *i.Title, age, *i.HTMLURL}) 98 | } 99 | 100 | if csv { 101 | t.RenderCSV() 102 | } else { 103 | t.Render() // Pretty-print table 104 | } 105 | }, 106 | } 107 | 108 | func init() { 109 | reportCmd.AddCommand(reportPRsCmd) 110 | 111 | reportPRsCmd.Flags().BoolVar(&csv, "csv", false, "Outputs data in CSV format") 112 | reportPRsCmd.Flags().StringVar(&author, "author", "app/hashicorp-copywrite", "Search for PRs created by a specific author") 113 | reportPRsCmd.Flags().StringVar(&status, "status", "open", "Filters on PR status, valid options are: open|closed|all") 114 | } 115 | -------------------------------------------------------------------------------- /cmd/report_repos.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | 10 | "github.com/hashicorp/copywrite/repodata" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // Flag variables 15 | var ( 16 | fields string 17 | fieldsArr []string 18 | githubOrgToAudit string 19 | ) 20 | 21 | // reportReposCmd represents the report command 22 | var reportReposCmd = &cobra.Command{ 23 | Use: "repos", 24 | Short: "Reports on GitHub repos matching specific criteria", 25 | Long: `Reports on GitHub repos matching specific criteria 26 | 27 | Outputs the fields you specify in a repodata.csv file in the working directory.`, 28 | PreRun: func(cmd *cobra.Command, args []string) { 29 | // validate flag input 30 | cmd.Println("Getting data... this might take a minute") 31 | var err error 32 | fieldsArr, err = repodata.ValidateInputFields(fields) 33 | if err != nil { 34 | cliLogger.Error("Error validating inputs", err) 35 | } 36 | cobra.CheckErr(err) 37 | }, 38 | Run: func(cmd *cobra.Command, args []string) { 39 | // get all public repos under org 40 | unfilteredRepos, err := repodata.GetRepos(githubOrgToAudit) 41 | if err != nil { 42 | cliLogger.Error(fmt.Sprintf("Error retrieving public repos for the \"%v\" org", githubOrgToAudit), err) 43 | } 44 | cobra.CheckErr(err) 45 | 46 | // remove archived repos 47 | filteredRepos := repodata.FilterRepos(unfilteredRepos) 48 | 49 | // transform repos into a string map 50 | outputData, err := repodata.Transform(filteredRepos) 51 | if err != nil { 52 | cliLogger.Error("Error transforming repo data", err) 53 | } 54 | cobra.CheckErr(err) 55 | 56 | t := newTableWriter(cmd.OutOrStdout()) 57 | t.AppendHeader(stringArrayToRow(fieldsArr)) 58 | 59 | // Populate rows 60 | for _, r := range outputData { 61 | // Filter the row to just contain the fields we care about 62 | row := make([]interface{}, 0) 63 | for _, k := range fieldsArr { 64 | row = append(row, r[k]) 65 | } 66 | 67 | t.AppendRow(row) 68 | } 69 | 70 | // Pretty-print the table 71 | t.Render() 72 | 73 | // Now let's render the CSV for backwards compatibility 74 | csvFile, err := os.Create("repodata.csv") 75 | if err != nil { 76 | cliLogger.Error("Error creating CSV of repo data", err) 77 | } 78 | cobra.CheckErr(err) 79 | 80 | t.SetOutputMirror(csvFile) 81 | t.RenderCSV() 82 | 83 | err = csvFile.Close() 84 | if err != nil { 85 | cliLogger.Error("Error closing file", err) 86 | } 87 | cobra.CheckErr(err) 88 | }, 89 | } 90 | 91 | func init() { 92 | reportCmd.AddCommand(reportReposCmd) 93 | 94 | reportReposCmd.Flags().StringVarP(&fields, "fields", "f", "Name,License,HTMLURL", "Repo attributes you wish to report on") 95 | reportReposCmd.Flags().StringVar(&githubOrgToAudit, "github-org", "hashicorp", "Sets the target GitHub org who's repos you wish to audit") 96 | } 97 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package cmd 5 | 6 | import ( 7 | "errors" 8 | "os" 9 | 10 | "github.com/hashicorp/copywrite/config" 11 | "github.com/hashicorp/copywrite/github/actions" 12 | "github.com/hashicorp/go-hclog" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var ( 17 | // Relative path to the Copywrite HCL config, defaults to .copywrite.hcl 18 | cfgPath string 19 | 20 | // This is the global configuration struct you should use to reference anything 21 | // from the .copywrite.hcl conf 22 | conf = config.MustNew() 23 | 24 | // This is a global instance of the GitHub Actions core helper library 25 | gha = actions.New(rootCmd.OutOrStdout()) 26 | 27 | // Named subsystem logger for copywrite-cli commands 28 | cliLogger hclog.Logger 29 | ) 30 | 31 | // rootCmd represents the base command when called without any subcommands 32 | var rootCmd = &cobra.Command{ 33 | Use: "copywrite", 34 | Short: "Utilities for managing copyright headers and license files", 35 | Long: `Copywrite provides utilities for managing copyright headers and license 36 | files in HashiCorp repos. 37 | 38 | You can use it to report on what licenses repos are using, add LICENSE files, 39 | and add or validate the presence of copyright headers on source code files.`, 40 | Version: GetVersion(), 41 | } 42 | 43 | // Execute adds all child commands to the root command and sets flags appropriately. 44 | // This is called by main.main(). It only needs to happen once to the rootCmd. 45 | func Execute() { 46 | err := rootCmd.Execute() 47 | if err != nil { 48 | // Attempt to publish a GitHub error annotation (if in GHA) before exiting 49 | gha.Error(actions.Annotation{Message: err.Error()}) 50 | os.Exit(1) 51 | } 52 | } 53 | 54 | func init() { 55 | cobra.OnInitialize(initConfig) 56 | cobra.OnInitialize(initLogger) 57 | 58 | // Let's group together the most commonly used commands in the help section 59 | rootCmd.AddGroup(&cobra.Group{ 60 | ID: "common", 61 | Title: "Common Commands:", 62 | }) 63 | 64 | rootCmd.PersistentFlags().StringVar(&cfgPath, "config", ".copywrite.hcl", "config file") 65 | 66 | // Let's make sure Cobra doesn't default to stderr 67 | rootCmd.SetOut(os.Stdout) 68 | } 69 | 70 | func initConfig() { 71 | // Load the .copywrite.hcl config file into the running config 72 | err := conf.LoadConfigFile(cfgPath) 73 | if errors.Is(err, os.ErrNotExist) { 74 | return 75 | } 76 | cobra.CheckErr(err) 77 | } 78 | 79 | func initLogger() { 80 | // Valid levels list: https://pkg.go.dev/github.com/hashicorp/go-hclog#Level 81 | logLevel := hclog.DefaultLevel 82 | 83 | // If we're running in GitHub Actions and runner debugging is enabled, let's 84 | // default to debug logging just to be extra friendly 85 | if os.Getenv("RUNNER_DEBUG") == "1" { 86 | logLevel = hclog.Debug 87 | } 88 | 89 | // If the `COPYWRITE_LOG_LEVEL` environment variable is explicitly set, let's 90 | // attempt to coerce the result into a proper level. If no matching level can 91 | // be found, hclog.LevelFromString() defaults to the "NoLevel" (a good thing) 92 | levelEnv, levelSet := os.LookupEnv("COPYWRITE_LOG_LEVEL") 93 | if levelSet { 94 | logLevel = hclog.LevelFromString(levelEnv) 95 | } 96 | 97 | hclog.Default().Named("cli") 98 | cliLogger = hclog.New(&hclog.LoggerOptions{ 99 | Name: "cli", 100 | Level: logLevel, 101 | Color: hclog.AutoColor, 102 | Output: os.Stdout, 103 | }) 104 | } 105 | -------------------------------------------------------------------------------- /cmd/utils.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/jedib0t/go-pretty/v6/table" 13 | "github.com/jedib0t/go-pretty/v6/text" 14 | ) 15 | 16 | var ( 17 | version = "dev" 18 | commit = "none" 19 | ) 20 | 21 | // GetVersion returns a version string corresponding to the current release. 22 | // The version and commit SHA are dynamically provided at build-time via 23 | // go-releaser's version/commit ldflags 24 | // https://goreleaser.com/cookbooks/using-main.version 25 | func GetVersion() string { 26 | return fmt.Sprintf("%v-%v", version, commit) 27 | } 28 | 29 | /////////////////////////////////// 30 | // Table Output Helpers // 31 | /////////////////////////////////// 32 | 33 | // Return a new table writer with style 😎 34 | func newTableWriter(out io.Writer) table.Writer { 35 | t := table.NewWriter() 36 | t.SetOutputMirror(out) 37 | 38 | t.SetStyle(table.StyleLight) 39 | 40 | t.Style().Name = "copywrite" 41 | 42 | // Headers are UPPERCASE by default, but let's lend that decision the caller 43 | t.Style().Format.Header = text.FormatDefault 44 | 45 | // Coloring it! 46 | t.Style().Color.Header = text.Colors{text.FgGreen} 47 | t.Style().Color.IndexColumn = text.Colors{text.FgCyan} 48 | 49 | // Borders are ugly, so let's get rid of them! 50 | t.Style().Options.DrawBorder = false 51 | t.Style().Options.SeparateColumns = false 52 | 53 | return t 54 | } 55 | 56 | func stringArrayToRow(m []string) table.Row { 57 | row := make([]interface{}, 0) 58 | for _, v := range m { 59 | row = append(row, v) 60 | } 61 | return row 62 | } 63 | 64 | /////////////////////////////////// 65 | // Pretty Print Output Helpers // 66 | /////////////////////////////////// 67 | 68 | // colorize expands the jedib0t/go-pretty/v6/text package by letting you supply 69 | // multiple ANSI codes to be escaped together. For example, this allows you to 70 | // make text both bold _and_ colored, instead of just one or the other. 71 | // 72 | // Example: 73 | // escaped := colorize("Hello, world!", text.Bold, text.FgCyan, text.BgBlack) 74 | // fmt.Println(escaped) 75 | func colorize(s string, colors ...text.Color) string { 76 | if len(colors) == 0 { 77 | return s // short circuit 78 | } 79 | codes := []string{} 80 | for _, c := range colors { 81 | codes = append(codes, strconv.Itoa(int(c))) 82 | } 83 | escSeq := fmt.Sprintf("\x1b[%vm", strings.Join(codes, ";")) 84 | return text.Escape(s, escSeq) 85 | } 86 | -------------------------------------------------------------------------------- /cmd/utils_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package cmd 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/jedib0t/go-pretty/v6/text" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func Test_colorize(t *testing.T) { 14 | // Let's take console abilities out of the picture 15 | text.EnableColors() 16 | 17 | tests := []struct { 18 | name string 19 | inputString string 20 | inputCodes []text.Color 21 | expectedOutput string 22 | }{ 23 | { 24 | name: "Output left alone when no codes are specified", 25 | inputString: "Hello, world!", 26 | inputCodes: []text.Color{}, 27 | expectedOutput: "Hello, world!", 28 | }, 29 | { 30 | name: "Output wrapped with stylistic escape sequence (bold)", 31 | inputString: "Hello, world!", 32 | inputCodes: []text.Color{text.Bold}, 33 | expectedOutput: "\x1b[1mHello, world!\x1b[0m", 34 | }, 35 | { 36 | name: "Output wrapped with colored escape sequence (FgCyan)", 37 | inputString: "Hello, world!", 38 | inputCodes: []text.Color{text.FgCyan}, 39 | expectedOutput: "\x1b[36mHello, world!\x1b[0m", 40 | }, 41 | { 42 | name: "Output properly wrapped with multiple escape sequences", 43 | inputString: "Hello, world!", 44 | inputCodes: []text.Color{text.Bold, text.FgCyan, text.BgBlack}, 45 | expectedOutput: "\x1b[1;36;40mHello, world!\x1b[0m", 46 | }, 47 | } 48 | for _, tt := range tests { 49 | t.Run(tt.name, func(t *testing.T) { 50 | actualOutput := colorize(tt.inputString, tt.inputCodes...) 51 | assert.Equal(t, tt.expectedOutput, actualOutput) 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package config 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/knadh/koanf" 12 | "github.com/knadh/koanf/parsers/hcl" 13 | "github.com/knadh/koanf/providers/confmap" 14 | "github.com/knadh/koanf/providers/file" 15 | "github.com/knadh/koanf/providers/posflag" 16 | "github.com/spf13/pflag" 17 | ) 18 | 19 | var ( 20 | // Use a period for delimiting sections of the config, e.g.: 21 | // project.copyright_year or dispatch.branch 22 | delim = "." 23 | ) 24 | 25 | // Project represents data needed for copyright and licensing statements inside 26 | // a specific project/repo 27 | type Project struct { 28 | CopyrightYear int `koanf:"copyright_year"` 29 | CopyrightHolder string `koanf:"copyright_holder"` 30 | HeaderIgnore []string `koanf:"header_ignore"` 31 | License string `koanf:"license"` 32 | 33 | // Upstream is optional and only used if a given repo pulls from another 34 | Upstream string `koanf:"upstream"` 35 | } 36 | 37 | // Dispatch represents data needed by the `copywrite dispatch` command, and is 38 | // used to control ignored repos, concurrency, and other information 39 | type Dispatch struct { 40 | // A unique identifier for the current batch of workflow runs 41 | BatchID string `koanf:"batch_id"` 42 | 43 | // The GitHub Branch to base workflow runs off of 44 | Branch string `koanf:"branch"` 45 | 46 | // The GitHub Organization who's repositories you want to audit 47 | GitHubOrgToAudit string `koanf:"github_org_to_audit"` 48 | 49 | // A list of repos that should be exempted from scans. 50 | // Repo names must be fully-qualified (i.e., include the org name), like so: 51 | // "hashicorp/copywrite" 52 | IgnoredRepos []string `koanf:"ignored_repos"` 53 | 54 | // Sleep time in seconds between polling operations 55 | Sleep int `koanf:"sleep"` 56 | 57 | // maxAttempts is the maximum number of times a worker will check if a 58 | // workflow is finished (sleeping between each attempt) before timing out 59 | MaxAttempts int `koanf:"max_attempts"` 60 | 61 | // The number of concurrent workers in the worker pool 62 | Workers int `koanf:"workers"` 63 | 64 | // The workflow file name to be used when triggering GitHub Actions jobs 65 | WorkflowFileName string `koanf:"workflow_file_name"` 66 | } 67 | 68 | // Config is a struct representing the data from a well-defined config file 69 | type Config struct { 70 | SchemaVersion int `koanf:"schema_version"` 71 | Project Project `koanf:"project"` 72 | Dispatch Dispatch `koanf:"dispatch"` 73 | 74 | // Global koanf instance 75 | globalKoanf *koanf.Koanf 76 | 77 | // Stores the absolute path of a .copywrite.hcl config object, if it exists 78 | absCfgPath string 79 | } 80 | 81 | // New returns a Config object initialized with default values 82 | func New() (*Config, error) { 83 | k := koanf.New(delim) 84 | c := &Config{ 85 | globalKoanf: k, 86 | } 87 | 88 | // Preload default config values 89 | defaults := map[string]interface{}{ 90 | "schema_version": 1, 91 | "project.copyright_holder": "HashiCorp, Inc.", 92 | } 93 | err := c.LoadConfMap(defaults) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | return c, nil 99 | } 100 | 101 | // MustNew returns a Config object initialized with default values 102 | // and panics if that is not possible 103 | func MustNew() *Config { 104 | c, err := New() 105 | if err != nil { 106 | panic(err) 107 | } 108 | 109 | return c 110 | } 111 | 112 | // LoadConfMap updates the running config with a key-value map, where 113 | // keys are delimited configuration key references. 114 | // 115 | // Example mapping: 116 | // 117 | // map[string]interface{}{ 118 | // "schema_version": 2, 119 | // "project.copyright_year": 2022, 120 | // "project.license": "MPL-2.0", 121 | // "dispatch.ignored_repos": []string{"foo", "bar"}, 122 | // } 123 | func (c *Config) LoadConfMap(mp map[string]interface{}) error { 124 | err := c.globalKoanf.Load(confmap.Provider(mp, delim), nil) 125 | if err != nil { 126 | return err 127 | } 128 | 129 | // Update the global config object with the new new 130 | err = c.globalKoanf.Unmarshal("", &c) 131 | if err != nil { 132 | return err 133 | } 134 | 135 | return nil 136 | } 137 | 138 | // LoadCommandFlags updates the running config with any command-line flags 139 | // based on a mapping of flag names to config keys 140 | // 141 | // Example mapping (flag name: config key): 142 | // 143 | // mapping := map[string]string{ 144 | // `license`: `project.license`, 145 | // `year`: `project.copyright_year`, 146 | // } 147 | // 148 | // Merge Behavior: 149 | // If a configuration value already exists (e.g., from previously reading a 150 | // .copywrite.hcl config file), those values will only be overwritten by default 151 | // flag values if clobberWithDefaults is true. If it is false, only values from 152 | // flags the user explicitly sets will be transferred to the configuration. 153 | // 154 | // Default flag options will be always be loaded if no value was previously set 155 | // in the running configuration. 156 | func (c *Config) LoadCommandFlags(flagSet *pflag.FlagSet, mapping map[string]string, clobberWithDefaults bool) error { 157 | // a new/blank koanf.New(delim) is used if we want to load all default flag 158 | // values, even if that would mean clobbering an already set config value. 159 | // If we wish to flip that behavior, we pass in the config's Koanf object 160 | // instead so that no clobbering exists. 161 | ko := c.globalKoanf 162 | if clobberWithDefaults { 163 | ko = koanf.New(delim) 164 | } 165 | 166 | // Parse out flag values 167 | p := posflag.ProviderWithFlag(flagSet, delim, ko, func(f *pflag.Flag) (string, interface{}) { 168 | // Transform the key name based on the provided mapping 169 | key := mapping[f.Name] 170 | 171 | // Retrieve the flag value 172 | val := posflag.FlagVal(flagSet, f) 173 | 174 | return key, val 175 | }) 176 | 177 | // Load up the new values into the global Koanf instance 178 | err := c.globalKoanf.Load(p, nil) 179 | if err != nil { 180 | return err 181 | } 182 | 183 | // Update the global config object with the new new 184 | err = c.globalKoanf.Unmarshal("", &c) 185 | if err != nil { 186 | return err 187 | } 188 | 189 | return nil 190 | } 191 | 192 | // LoadConfigFile takes a path to an HCL config file and 193 | // merges it with the running config 194 | // 195 | // Example HCL config: 196 | // 197 | // schema_version = 1 198 | // project { 199 | // copyright_year = 2022 200 | // license = "MPL-2.0" 201 | // } 202 | func (c *Config) LoadConfigFile(cfgPath string) error { 203 | abs, err := filepath.Abs(cfgPath) 204 | if err != nil { 205 | return fmt.Errorf("Unable to determine config path: %w", err) 206 | } 207 | c.absCfgPath = abs 208 | 209 | // If a config file exists, let's load it 210 | if _, err := os.Stat(abs); err != nil { 211 | return fmt.Errorf("Config file doesn't exist: %w", err) 212 | } 213 | 214 | // Load HCL config. 215 | err = c.globalKoanf.Load(file.Provider(abs), hcl.Parser(true)) 216 | if err != nil { 217 | return fmt.Errorf("Unable to load config: %w", err) 218 | } 219 | 220 | // Attempt to suss out a Config struct 221 | err = c.globalKoanf.Unmarshal("", &c) 222 | if err != nil { 223 | return fmt.Errorf("Unable to unmarshal config: %w", err) 224 | } 225 | 226 | return nil 227 | } 228 | 229 | // Sprint returns a textual version of the current running config. 230 | // The string is newline-delimited and contains alphabetical key -> value pairs 231 | func (c *Config) Sprint() string { 232 | return c.globalKoanf.Sprint() 233 | } 234 | 235 | // GetConfigPath returns the absolute path of the last loaded HCL config. 236 | // If LoadConfigFile() has not been called, it will return an empty string. 237 | func (c *Config) GetConfigPath() string { 238 | return c.absCfgPath 239 | } 240 | -------------------------------------------------------------------------------- /config/testdata/config_with_schema_version.hcl: -------------------------------------------------------------------------------- 1 | schema_version = 42 2 | -------------------------------------------------------------------------------- /config/testdata/dispatch/full_dispatch.hcl: -------------------------------------------------------------------------------- 1 | schema_version = 78 2 | 3 | dispatch { 4 | batch_id = "aZ0-9" 5 | 6 | branch = "main" 7 | 8 | github_org_to_audit = "hashicorp-forge" 9 | 10 | ignored_repos = [ 11 | "org/repo1", 12 | "org/repo2", 13 | ] 14 | 15 | sleep = 42 16 | 17 | max_attempts = 3 18 | 19 | workers = 12 20 | 21 | workflow_file_name = "repair-repo-headers.yml" 22 | } 23 | -------------------------------------------------------------------------------- /config/testdata/empty_config.hcl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/copywrite/a3d721be191c2a2d0c5afe3fd4647b2ca5f7db44/config/testdata/empty_config.hcl -------------------------------------------------------------------------------- /config/testdata/project/copyright_holder_only.hcl: -------------------------------------------------------------------------------- 1 | project { 2 | copyright_holder = "Dummy Corporation" 3 | } 4 | -------------------------------------------------------------------------------- /config/testdata/project/copyright_year_only.hcl: -------------------------------------------------------------------------------- 1 | project { 2 | copyright_year = 9001 3 | } 4 | -------------------------------------------------------------------------------- /config/testdata/project/full_project.hcl: -------------------------------------------------------------------------------- 1 | schema_version = 12 2 | 3 | project { 4 | copyright_year = 9001 5 | copyright_holder = "Dummy Corporation" 6 | license = "NOT_A_VALID_SPDX" 7 | 8 | header_ignore = [ 9 | "asdf.go", 10 | "*.css", 11 | "**/vendor/**.go", 12 | ] 13 | 14 | upstream = "hashicorp/super-secret-private-repo" 15 | } 16 | -------------------------------------------------------------------------------- /config/testdata/project/license_only.hcl: -------------------------------------------------------------------------------- 1 | project { 2 | license = "NOT_A_VALID_SPDX" 3 | } 4 | -------------------------------------------------------------------------------- /config/testdata/project/partial_project.hcl: -------------------------------------------------------------------------------- 1 | project { 2 | copyright_year = 9001 3 | license = "NOT_A_VALID_SPDX" 4 | } 5 | -------------------------------------------------------------------------------- /dispatch/dispatch.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package dispatch 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "time" 10 | 11 | "github.com/google/go-github/v45/github" 12 | "github.com/hashicorp/go-hclog" 13 | ) 14 | 15 | // Result reports on the outcome of a given job, including if it was successful 16 | // or not, and (if unsuccessful) details on any errors that ocurred 17 | type Result struct { 18 | Name string 19 | Success bool 20 | Error error 21 | } 22 | 23 | // Options provides a way to define how frequently the GitHub APIs should be 24 | // polled for results, as well as the maximum number of attempts before stopping 25 | type Options struct { 26 | SecondsBetweenPolls int 27 | MaxAttempts int 28 | Logger hclog.Logger 29 | BranchRef string 30 | BatchID string 31 | WorkflowFileName string 32 | GitHubOwner string 33 | GitHubRepo string 34 | } 35 | 36 | // WaitRunFinished watches a GitHub Actions Workflow Run and returns once the 37 | // workflow has finished processing 38 | func WaitRunFinished(client *github.Client, opts Options, run github.WorkflowRun) error { 39 | // Short circuit if stuff went really fast 40 | if *run.Status == "completed" { 41 | return nil 42 | } 43 | 44 | for i := 0; i < opts.MaxAttempts; i++ { 45 | opts.Logger.Debug(fmt.Sprintf("Waiting %d of 5 for run to finish: %s", i, *run.Name)) 46 | time.Sleep(time.Duration(opts.SecondsBetweenPolls) * time.Second) 47 | 48 | this, _, err := client.Actions.GetWorkflowRunByID(context.Background(), opts.GitHubOwner, opts.GitHubRepo, *run.ID) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | switch *this.Status { 54 | case "completed": 55 | return nil 56 | case "queued": 57 | // Do nothing, keep watching 58 | case "in_progress": 59 | // Do nothing, keep watching 60 | default: 61 | return fmt.Errorf("Workflow \"%s\" is in unrepairable state: %s", *run.Name, *this.Status) 62 | } 63 | } 64 | 65 | return fmt.Errorf("Timed out polling for workflow job") 66 | } 67 | 68 | // FindRun finds the most recent GitHub Actions run matching a given run name. 69 | // 70 | // FindRun requires that the `run-name:` tag in a workflow match the `runName` 71 | // input. For more information about how `run-name:` works in GitHub Actions, 72 | // refer to: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#run-name 73 | // 74 | // Polling is defined by the `Options.SecondsBetweenPolls` parameter. 75 | // If no run is returned after `Options.MaxAttempts` attempts, an error is returned 76 | func FindRun(client *github.Client, opts Options, runName string) (github.WorkflowRun, error) { 77 | searchOpts := &github.ListWorkflowRunsOptions{ 78 | Branch: opts.BranchRef, 79 | // Only search for workflow runs from today 80 | Created: time.Now().Format("2006-01-02"), 81 | } 82 | 83 | for i := 0; i < opts.MaxAttempts; i++ { 84 | opts.Logger.Debug(fmt.Sprintf("Attempt %d of %d to find run for %s", i, opts.MaxAttempts, runName)) 85 | 86 | runs, _, err := client.Actions.ListWorkflowRunsByFileName(context.Background(), opts.GitHubOwner, opts.GitHubRepo, opts.WorkflowFileName, searchOpts) 87 | if err != nil { 88 | // TODO: handle rate limiting 89 | return github.WorkflowRun{}, fmt.Errorf("Error attempting to find the \"%s\" workflow run: %w", runName, err) 90 | } 91 | 92 | for _, v := range runs.WorkflowRuns { 93 | if *v.Name == runName { 94 | return *v, nil 95 | } 96 | } 97 | 98 | time.Sleep(time.Duration(opts.SecondsBetweenPolls) * time.Second) 99 | } 100 | return github.WorkflowRun{}, fmt.Errorf("Timed out polling for workflow job") 101 | } 102 | 103 | // Worker spawns an instance of a goroutine that listens for new job requests 104 | // and then processes those requests until complete. Multiple workers can be 105 | // instantiated to create a pool for concurrent processing. 106 | // 107 | // Workers create a GitHub Actions workflow run and follow the status of the job 108 | // until it completes or errors out. The `results` channel is populated with 109 | // the outcome of any jobs. 110 | func Worker(client *github.Client, opts Options, id int, jobs <-chan string, results chan<- Result) { 111 | for repo := range jobs { 112 | opts.Logger.Info(fmt.Sprint("worker ", id, " started job ", repo)) 113 | 114 | // The run name is in the form of `: Audit `, e.g.: 115 | // 01GFS35ZP6MQJHBF4QX1EFD6Y3: Audit go-hclog 116 | // TODO: This formatting is highly coupled to the `run-name:` tag in the 117 | // `repair-repo-license.yml` file. Perhaps explore other ways of declaring 118 | // this format only once instead of twice. 119 | runName := fmt.Sprintf("%s: Audit %s", opts.BatchID, repo) 120 | 121 | // Dispatch a Github Actions job to audit the given repo 122 | event := github.CreateWorkflowDispatchEventRequest{ 123 | Ref: opts.BranchRef, 124 | Inputs: map[string]interface{}{ 125 | "repo": repo, 126 | "unique_id": opts.BatchID, 127 | "dry_run": "false", 128 | }, 129 | } 130 | 131 | opts.Logger.Debug(fmt.Sprintf("Starting workflow run: %s", runName)) 132 | _, err := client.Actions.CreateWorkflowDispatchEventByFileName(context.Background(), opts.GitHubOwner, opts.GitHubRepo, opts.WorkflowFileName, event) 133 | if err != nil { 134 | results <- Result{ 135 | Name: repo, 136 | Success: false, 137 | Error: err, 138 | } 139 | opts.Logger.Debug(fmt.Sprintf("Failed workflow run: %s", runName)) 140 | continue 141 | } 142 | 143 | // GitHub Actions only returns a 200 OK when dispatching a job. It doesn't 144 | // return any Job ID or other identifying info, so we have to poll GitHub's 145 | // API to grab info about the actual run we spawned. 146 | run, err := FindRun(client, opts, runName) 147 | if err != nil { 148 | results <- Result{ 149 | Name: repo, 150 | Success: false, 151 | Error: err, 152 | } 153 | opts.Logger.Debug(fmt.Sprintf("Failed workflow run: %s", runName)) 154 | continue 155 | } 156 | 157 | // Now that we have identified a Job ID for the run we care about, let's 158 | // follow it until the run is done (successful, failed, or cancelled) 159 | err = WaitRunFinished(client, opts, run) 160 | if err != nil { 161 | results <- Result{ 162 | Name: repo, 163 | Success: false, 164 | Error: err, 165 | } 166 | opts.Logger.Debug(fmt.Sprintf("Failed workflow run: %s", runName)) 167 | continue 168 | } 169 | 170 | // All done here! No errors, so let's send a successful result back 171 | opts.Logger.Info(fmt.Sprint("worker ", id, " finished job ", repo)) 172 | results <- Result{ 173 | Name: repo, 174 | Success: true, 175 | Error: nil, 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /github/actions/core.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package actions 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "io" 10 | "os" 11 | "strings" 12 | 13 | "github.com/jedib0t/go-pretty/text" 14 | ) 15 | 16 | /////////////////////////////////// 17 | // GitHub Actions Helpers // 18 | /////////////////////////////////// 19 | 20 | // GHA helps write output for GitHub Actions-specific cases 21 | type GHA struct { 22 | outWriter io.Writer 23 | 24 | isGHA bool 25 | } 26 | 27 | // Annotation represents a message that can optionally be attributed to a 28 | // specific file location in GitHub. It is shown in the Actions Workflow Run UI 29 | type Annotation struct { 30 | // The annotation's content body 31 | Message string 32 | 33 | // (optional) Custom title 34 | Title string 35 | 36 | // (optional) Filename 37 | File string 38 | 39 | // (optional) Line number, starting at 1 40 | Line int 41 | 42 | // (optional) Ending line number, starting at 1 43 | EndLine int 44 | 45 | // Col and EndColumn currently left out 46 | } 47 | 48 | // ErrorNotInGHA is the error returned when a function can only 49 | // execute in GitHub Actions, but the current execution 50 | // environment is NOT GitHub Actions 51 | var ErrorNotInGHA = errors.New("Not in GitHub Actions") 52 | 53 | // New returns a new GitHub Actions Writer 54 | func New(out io.Writer) *GHA { 55 | // Default to looking up if we're running in GitHub Actions 56 | isGHA := os.Getenv("GITHUB_ACTIONS") == "true" 57 | return &GHA{outWriter: out, isGHA: isGHA} 58 | } 59 | 60 | // IsGHA returns true if the program is executing inside of GitHub Actions 61 | func (gha *GHA) IsGHA() bool { 62 | return gha.isGHA 63 | } 64 | 65 | // DisableGHAOutput forcibly disables GitHub Actions-specific output types (e.g., groups) 66 | func (gha *GHA) DisableGHAOutput(in bool) { 67 | gha.isGHA = false 68 | } 69 | 70 | // EnableGHAOutput forcibly enables GitHub Actions-specific output types (e.g., groups) 71 | func (gha *GHA) EnableGHAOutput() { 72 | gha.isGHA = true 73 | } 74 | 75 | // StartGroup creates a GitHub Actions logging group 76 | // https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#grouping-log-lines 77 | func (gha *GHA) StartGroup(name string) { 78 | if !gha.IsGHA() { 79 | gha.println(text.Bold.Sprint(name)) 80 | return 81 | } 82 | 83 | out := "::group::" + name 84 | gha.println(out) 85 | } 86 | 87 | // EndGroup ends a GitHub Actions logging group 88 | // https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#grouping-log-lines 89 | func (gha *GHA) EndGroup() { 90 | if !gha.IsGHA() { 91 | return 92 | } 93 | 94 | gha.println("::endgroup::") 95 | } 96 | 97 | // SetOutput generates a GitHub Actions output for the current job 98 | // https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter 99 | func (gha *GHA) SetOutput(name, value string) error { 100 | content := fmt.Sprintf("%s=%s", name, value) 101 | return gha.appendToFile("GITHUB_OUTPUT", content) 102 | } 103 | 104 | // ExportVariable makes an environment variable available to subsequent steps 105 | // https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-environment-variable 106 | func (gha *GHA) ExportVariable(name, value string) error { 107 | content := fmt.Sprintf("%s=%s", name, value) 108 | return gha.appendToFile("GITHUB_ENV", content) 109 | } 110 | 111 | // SetJobSummary appends markdown displayed on the summary page of a workflow run 112 | // https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-environment-variable 113 | func (gha *GHA) SetJobSummary(content string) error { 114 | return gha.appendToFile("GITHUB_STEP_SUMMARY", content) 115 | } 116 | 117 | // appendToFile is an internal helper for adding content to a given file in a 118 | // safe and consistent way. It can be used for populating GHA Environment Files. 119 | // A newline will automatically be added to the content string if not present 120 | // 121 | // https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#environment-files 122 | func (gha *GHA) appendToFile(fileEnvVar string, content string) error { 123 | path, exists := os.LookupEnv(fileEnvVar) 124 | if !gha.IsGHA() || !exists { 125 | return fmt.Errorf("Unable to set modify GitHub Actions environment file %s: %w", fileEnvVar, ErrorNotInGHA) 126 | } 127 | 128 | // Short cut if no content is provided 129 | if content == "" { 130 | return nil 131 | } 132 | 133 | // append a newline if not currently present 134 | if !strings.HasSuffix(content, "\n") { 135 | content = content + "\n" 136 | } 137 | 138 | // Open the file and attempt to write to it 139 | f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 140 | if err != nil { 141 | return err 142 | } 143 | 144 | defer f.Close() 145 | 146 | _, err = f.WriteString(content) 147 | if err != nil { 148 | return err 149 | } 150 | 151 | return nil 152 | } 153 | 154 | // Notice creates a notice message and prints the message to the log 155 | // This message will create an annotation, which can associate the message with 156 | // a particular file in your repository. Optionally, your message can specify a 157 | // position within the file. 158 | func (gha *GHA) Notice(a Annotation) { gha.newAnnotation("notice", a) } 159 | 160 | // Warning creates a warning message and prints the message to the log. 161 | // This message will create an annotation, which can associate the message with 162 | // a particular file in your repository. Optionally, your message can specify a 163 | // position within the file. 164 | func (gha *GHA) Warning(a Annotation) { gha.newAnnotation("warning", a) } 165 | 166 | // Error creates an error message and prints the message to the log. 167 | // This message will create an annotation, which can associate the message with 168 | // a particular file in your repository. Optionally, your message can specify a 169 | // position within the file. 170 | func (gha *GHA) Error(a Annotation) { gha.newAnnotation("error", a) } 171 | 172 | // newAnnotation is an internal helper for creating notice, warning, and error 173 | // annotations given a well-formed Annotation struct as input 174 | // 175 | // T specifies the annotation type, usually "notice", "warning", or "error" 176 | // 177 | // a specifies the content of the annotation 178 | func (gha *GHA) newAnnotation(T string, a Annotation) { 179 | if !gha.IsGHA() { 180 | return 181 | } 182 | 183 | // TODO: maybe reflect would be cleaner for this? 184 | attributes := []string{} 185 | if a.Title != "" { 186 | attributes = append(attributes, fmt.Sprintf("title=%s", a.Title)) 187 | } 188 | if a.File != "" { 189 | attributes = append(attributes, fmt.Sprintf("file=%s", a.File)) 190 | } 191 | if a.Line != 0 { 192 | attributes = append(attributes, fmt.Sprintf("line=%d", a.Line)) 193 | } 194 | if a.EndLine != 0 { 195 | attributes = append(attributes, fmt.Sprintf("endLine=%d", a.EndLine)) 196 | } 197 | 198 | // General format should be: 199 | // "::error file={name},line={line},endLine={endLine},title={title}::{message}" 200 | // "::error file=app.js,line=1,title=Syntax Error::Missing semicolon" 201 | str := fmt.Sprintf("::%s %s::%s", T, strings.Join(attributes, ","), a.Message) 202 | gha.println(str) 203 | } 204 | 205 | // println is an internal helper for printing to the expected output io.Writer 206 | func (gha *GHA) println(i ...interface{}) { 207 | fmt.Fprintln(gha.outWriter, i...) 208 | } 209 | -------------------------------------------------------------------------------- /github/client.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package github 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "net/http" 10 | "os" 11 | "strings" 12 | 13 | "github.com/bradleyfalzon/ghinstallation/v2" 14 | "github.com/google/go-github/v45/github" 15 | "github.com/hashicorp/go-hclog" 16 | "github.com/knadh/koanf" 17 | "github.com/knadh/koanf/parsers/dotenv" 18 | "github.com/knadh/koanf/parsers/yaml" 19 | "github.com/knadh/koanf/providers/env" 20 | "github.com/knadh/koanf/providers/file" 21 | "github.com/mitchellh/go-homedir" 22 | "golang.org/x/oauth2" 23 | ) 24 | 25 | var logger = hclog.L() 26 | 27 | // GHClient is a wrapper to access Github Client's API endpoints easily 28 | type GHClient struct { 29 | gh *github.Client 30 | } 31 | 32 | // GHClientConfig is the configuration portion of the GH client (mostly for GH App) 33 | type GHClientConfig struct { 34 | appID int64 35 | instID int64 36 | appPEM string 37 | } 38 | 39 | // Raw is a util function to access the Github Client directly 40 | func (c *GHClient) Raw() *github.Client { 41 | return c.gh 42 | } 43 | 44 | // getGHAppConfig looks for Github App configurations and sets them appropriately. 45 | // if configuration is not found, return false 46 | func getGHAppConfig() (cc GHClientConfig, exists bool) { 47 | k := koanf.New(".") 48 | var err error 49 | 50 | // Start by loading any .env file that exists 51 | err = k.Load(file.Provider(".env"), dotenv.Parser()) 52 | if err != nil { 53 | logger.Debug(fmt.Sprintf("Error reading .env configuration file: %v", err)) 54 | } 55 | 56 | // If environment variable are present, give preference to them 57 | // (this will overwrite values for any keys that also exist in the .env file) 58 | err = k.Load(env.Provider("", ".", nil), nil) 59 | if err != nil { 60 | logger.Debug(fmt.Sprintf("Error reading environment variables: %v", err)) 61 | } 62 | 63 | cc.appID = k.Int64("APP_ID") 64 | cc.instID = k.Int64("INSTALLATION_ID") 65 | cc.appPEM = strings.ReplaceAll(k.String("APP_PEM"), "\\n", "\n") 66 | 67 | if cc.appPEM != "" && cc.appID != 0 && cc.instID != 0 { 68 | return cc, true 69 | } 70 | 71 | // Nothing worked 72 | logger.Debug("Problem with retrieving Github App identifiers, skipping GHApp configuration") 73 | return GHClientConfig{}, false 74 | } 75 | 76 | // getGitHubCLIConfig attempts to find a GitHub CLI (gh) configuration in the 77 | // user's home directory. If it encounters any problems doing so, or if the 78 | // configuration is missing/malformed, it will exit early with exists = false 79 | func getGitHubCLIConfig() (token string, exists bool) { 80 | // Use "/" as the delimiter instead of "." because the GH CLI uses "." in YAML 81 | // key names, such as "github.com:" 82 | var k = koanf.New("/") 83 | 84 | errorString := "Unable to retrieve GitHub authentication via gh CLI config" 85 | 86 | configPath, err := homedir.Expand("~/.config/gh/hosts.yml") 87 | if err != nil { 88 | return "", false 89 | } 90 | 91 | // Config file is in the following format: 92 | // ───────┬─────────────────────────────────────────────────────────── 93 | // │ File: /Users/octocat/.config/gh/hosts.yml 94 | // ───────┼─────────────────────────────────────────────────────────── 95 | // 1 │ github.com: 96 | // 2 │ user: octocat 97 | // 3 │ oauth_token: gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 98 | // 4 │ git_protocol: https 99 | 100 | err = k.Load(file.Provider(configPath), yaml.Parser()) 101 | if err != nil { 102 | logger.Debug(fmt.Sprintf("%v: %v", errorString, err)) 103 | return "", false 104 | } 105 | 106 | // We only care about github.com as the host right now, not self-hosted GitHub 107 | token = k.String("github.com/oauth_token") 108 | if token != "" { 109 | return token, true 110 | } 111 | 112 | logger.Debug(errorString) 113 | return "", false 114 | } 115 | 116 | // NewGHClient uses the copyright Github App for client requests 117 | func NewGHClient() *GHClient { 118 | 119 | // Shared transport to reuse TCP connections and default to background context 120 | tr := http.DefaultTransport 121 | ctx := context.Background() 122 | 123 | // First, let's see if we can use GitHub App creds 124 | // This serves the use case of running as `hashicorp-copywrite[bot]` for 125 | // automatically scanning repos on a periodic basis. 126 | if cc, exists := getGHAppConfig(); exists { 127 | itr, err := ghinstallation.New(tr, cc.appID, cc.instID, []byte(cc.appPEM)) 128 | if err != nil { 129 | logger.Error("Problem instantiating GH App transport") 130 | } 131 | 132 | client := github.NewClient(&http.Client{Transport: itr}) 133 | logger.Info("Successfully established GH App client, requests will be made from hashicorp-copywrite.") 134 | return &GHClient{gh: client} 135 | } 136 | 137 | // If GitHub App creds can't be found or are malformed, fall back to using 138 | // the `GITHUB_TOKEN` environment variable. 139 | // This is a common use case for per-repo GitHub Actions, as an example 140 | if token, exists := os.LookupEnv("GITHUB_TOKEN"); exists { 141 | logger.Info("Using discovered Github PAT") 142 | 143 | ts := oauth2.StaticTokenSource( 144 | &oauth2.Token{AccessToken: token}, 145 | ) 146 | tc := oauth2.NewClient(ctx, ts) 147 | return &GHClient{gh: github.NewClient(tc)} 148 | } 149 | 150 | // Fallback to seeing if the user happens to have the GitHub CLI tool (gh) 151 | // installed, at which point we can examine its config and extract a token 152 | if token, exists := getGitHubCLIConfig(); exists { 153 | logger.Info("Using discovered GitHub CLI Config Token") 154 | 155 | ts := oauth2.StaticTokenSource( 156 | &oauth2.Token{AccessToken: token}, 157 | ) 158 | tc := oauth2.NewClient(ctx, ts) 159 | return &GHClient{gh: github.NewClient(tc)} 160 | } 161 | 162 | // If all else fails, fallback to an unauthenticated client 163 | // This only gives access to public information 164 | logger.Info("No Github auth credentials found, using unauthenticated GH Client") 165 | return &GHClient{gh: github.NewClient(nil)} 166 | } 167 | -------------------------------------------------------------------------------- /github/repo.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package github 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | 10 | "github.com/cli/go-gh/v2/pkg/repository" 11 | "github.com/google/go-github/v45/github" 12 | ) 13 | 14 | // GHRepo is a repo 15 | type GHRepo struct { 16 | Owner string 17 | Name string 18 | } 19 | 20 | // DiscoverRepo attempts to find if the current directory is related to a 21 | // GitHub repo and, if so, what the organization name and repo name are 22 | // 23 | // This function will return an error if more than one GitHub repos are 24 | // associated with the given folder. This can happen if multiple git upstreams 25 | // defined. 26 | func DiscoverRepo() (GHRepo, error) { 27 | repo, err := repository.Current() 28 | if err != nil { 29 | return GHRepo{}, fmt.Errorf("unable to determine if the current directory relates to a GitHub repo: %v", err) 30 | } 31 | 32 | return GHRepo{ 33 | Name: repo.Name, 34 | Owner: repo.Owner, 35 | }, nil 36 | } 37 | 38 | // GetRepoCreationYear takes in a repo and uses the GitHub API to determine the 39 | // year it was created. 40 | // 41 | // This is typically used to infer when the original copyright date is, but it 42 | // should be noted that certain circumstances can cause the value returned may 43 | // be unsuitable for use as the original copyright date. Specifically, it is 44 | // possible for an older project to be recreated in a newer repo, or for the 45 | // repo to be made public at a later date, at which point the year of creation 46 | // and year of copyright may differ. 47 | func GetRepoCreationYear(client *github.Client, repo GHRepo) (int, error) { 48 | data, _, err := client.Repositories.Get(context.Background(), repo.Owner, repo.Name) 49 | if err != nil { 50 | return 0, err 51 | } 52 | 53 | year := data.CreatedAt.Year() 54 | if year == 0 { 55 | return 0, fmt.Errorf("year returned from GitHub API is invalid \"%v\"", year) 56 | } 57 | 58 | return year, nil 59 | } 60 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hashicorp/copywrite 2 | 3 | go 1.23 4 | 5 | toolchain go1.23.1 6 | 7 | require ( 8 | github.com/AlecAivazis/survey/v2 v2.3.7 9 | github.com/bmatcuk/doublestar/v4 v4.6.0 10 | github.com/bradleyfalzon/ghinstallation/v2 v2.5.0 11 | github.com/hashicorp/go-hclog v1.5.0 12 | github.com/jedib0t/go-pretty/v6 v6.4.6 13 | github.com/knadh/koanf v1.5.0 14 | github.com/mattn/go-isatty v0.0.20 15 | github.com/mergestat/timediff v0.0.3 16 | github.com/mitchellh/go-homedir v1.1.0 17 | github.com/mitchellh/mapstructure v1.5.0 18 | github.com/spf13/afero v1.9.5 19 | github.com/spf13/cobra v1.6.1 20 | github.com/spf13/pflag v1.0.5 21 | github.com/stretchr/testify v1.8.2 22 | github.com/thanhpk/randstr v1.0.4 23 | golang.org/x/oauth2 v0.8.0 24 | golang.org/x/sync v0.10.0 25 | ) 26 | 27 | require ( 28 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect 29 | github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect 30 | github.com/cli/safeexec v1.0.0 // indirect 31 | github.com/cloudflare/circl v1.3.7 // indirect 32 | github.com/fatih/color v1.13.0 // indirect 33 | github.com/fsnotify/fsnotify v1.5.4 // indirect 34 | github.com/go-openapi/errors v0.20.2 // indirect 35 | github.com/go-openapi/strfmt v0.21.3 // indirect 36 | github.com/golang-jwt/jwt/v4 v4.5.1 // indirect 37 | github.com/golang/protobuf v1.5.2 // indirect 38 | github.com/google/go-github/v53 v53.0.0 // indirect 39 | github.com/google/go-querystring v1.1.0 // indirect 40 | github.com/hashicorp/hcl v1.0.0 // indirect 41 | github.com/inconshreveable/mousetrap v1.0.1 // indirect 42 | github.com/joho/godotenv v1.3.0 // indirect 43 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 44 | github.com/kr/pretty v0.3.0 // indirect 45 | github.com/mattn/go-colorable v0.1.13 // indirect 46 | github.com/mattn/go-runewidth v0.0.15 // indirect 47 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 48 | github.com/mitchellh/copystructure v1.2.0 // indirect 49 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 50 | github.com/oklog/ulid v1.3.1 // indirect 51 | github.com/pelletier/go-toml v1.9.5 // indirect 52 | github.com/rivo/uniseg v0.4.7 // indirect 53 | go.mongodb.org/mongo-driver v1.10.0 // indirect 54 | golang.org/x/crypto v0.31.0 // indirect 55 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect 56 | golang.org/x/net v0.33.0 // indirect 57 | golang.org/x/sys v0.28.0 // indirect 58 | golang.org/x/term v0.27.0 // indirect 59 | golang.org/x/text v0.21.0 // indirect 60 | google.golang.org/appengine v1.6.7 // indirect 61 | google.golang.org/protobuf v1.28.0 // indirect 62 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 63 | ) 64 | 65 | require ( 66 | github.com/cli/go-gh/v2 v2.11.2 67 | github.com/davecgh/go-spew v1.1.1 // indirect 68 | github.com/google/go-github/v45 v45.2.0 69 | github.com/jedib0t/go-pretty v4.3.0+incompatible 70 | github.com/pmezard/go-difflib v1.0.0 // indirect 71 | github.com/samber/lo v1.37.0 72 | gopkg.in/yaml.v3 v3.0.1 // indirect 73 | ) 74 | -------------------------------------------------------------------------------- /licensecheck/README.md: -------------------------------------------------------------------------------- 1 | # License Check 2 | 3 | This module provides helper functions to validating and remediating any problems with a `LICENSE` file. 4 | 5 | ## Entry 6 | 7 | The `Entry(dirPath string)` function takes in a directory path and will do the following: 8 | 9 | - Check if any files appear to be licenses 10 | - If no files are found, a stubbed out `addLicenseFile` function is called 11 | - If a file is found but it does not adhere to the `LICENSE` desired nomenclature, it will be renamed 12 | - If a file is found that matches the desired naming scheme, it is left alone 13 | - If multiple files are found, an error will be returned 14 | 15 | ## License File Criteria 16 | 17 | Potential LICENSE files are found by searching all files in a directory to find matching files with the name `LICENSE` 18 | with or without `.txt` or `.md` extensions in a case-insensitive manner. As an example, the following all qualify: 19 | 20 | - `LICENSE` 21 | - `LICENSE.txt` 22 | - `LICENSE.md` 23 | - `license.TXT` 24 | - `LiCeNsE` (for those who woke up and chose chaos) 25 | 26 | ## Testing 27 | 28 | Due to the nature of mutating the filesystem, some functions in this module are not suited to being tested with a more 29 | common `testdata` paradigm. Instead, `testing/TempDir()` is used to generate an ephemeral testing directory for each 30 | sub-test. 31 | -------------------------------------------------------------------------------- /licensecheck/copyright.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package licensecheck 5 | 6 | import ( 7 | "bytes" 8 | "os" 9 | ) 10 | 11 | // HasCopyright reports whether or not a file contains a copyright statement 12 | // It makes no promises as to the validity of the copyright statement, however! 13 | // If you wish to validate the contents of the statement, use hasValidCopyright 14 | func HasCopyright(filePath string) (bool, error) { 15 | // just check if the word "copyright" exists in the header 16 | // TODO: maybe further check the formation of the copyright statement, such as 17 | // ensuring a holder exists, etc. 18 | return HasMatchingCopyright(filePath, "copyright", false) 19 | } 20 | 21 | // HasMatchingCopyright takes an explicit copyright statement and validates that 22 | // a given file contains that string in the header (first 1k chars) 23 | func HasMatchingCopyright(filePath string, copyrightStatement string, caseSensitive bool) (bool, error) { 24 | b, err := os.ReadFile(filePath) 25 | if err != nil { 26 | return false, err 27 | } 28 | 29 | // Check the first 300 characters 30 | n := 300 31 | if len(b) < n { 32 | n = len(b) 33 | } 34 | 35 | expected := []byte(copyrightStatement) 36 | header := b[:n] 37 | if !caseSensitive { 38 | header = bytes.ToLower(header) 39 | expected = bytes.ToLower(expected) 40 | } 41 | return bytes.Contains(header, expected), nil 42 | } 43 | -------------------------------------------------------------------------------- /licensecheck/copyright_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package licensecheck 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/spf13/afero" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestHasMatchingCopyright(t *testing.T) { 14 | AppFs := afero.NewOsFs() 15 | tempDir := t.TempDir() 16 | 17 | desiredCopyrightString := "Copyright (c) 2022 HashiCorp, Inc." 18 | 19 | cases := []struct { 20 | description string 21 | fileContents string 22 | caseSensitive bool 23 | expectedValid bool 24 | expectedError error 25 | }{ 26 | { 27 | description: "Missing copyright statement should fail", 28 | fileContents: "", 29 | caseSensitive: false, 30 | expectedValid: false, 31 | expectedError: nil, 32 | }, 33 | { 34 | description: "Valid copyright statement should pass", 35 | fileContents: "Copyright (c) 2022 HashiCorp, Inc.", 36 | caseSensitive: false, 37 | expectedValid: true, 38 | expectedError: nil, 39 | }, 40 | { 41 | description: "Valid copyright statement with language headers should pass", 42 | fileContents: "#!/bin/bash\nCopyright (c) 2022 HashiCorp, Inc.", 43 | caseSensitive: false, 44 | expectedValid: true, 45 | expectedError: nil, 46 | }, 47 | { 48 | description: "Malformed copyright statement without symbol should fail", 49 | fileContents: "Copyright 2022 HashiCorp, Inc.", 50 | caseSensitive: false, 51 | expectedValid: false, 52 | expectedError: nil, 53 | }, 54 | { 55 | description: "Malformed copyright statement without year should fail", 56 | fileContents: "Copyright (c) HashiCorp, Inc.", 57 | caseSensitive: false, 58 | expectedValid: false, 59 | expectedError: nil, 60 | }, 61 | { 62 | description: "Malformed copyright statement without holder should fail", 63 | fileContents: "Copyright (c) 2022", 64 | caseSensitive: false, 65 | expectedValid: false, 66 | expectedError: nil, 67 | }, 68 | { 69 | description: "Malformed copyright statement with year range should fail", 70 | fileContents: "Copyright 1995-2022 HashiCorp, Inc.", 71 | caseSensitive: false, 72 | expectedValid: false, 73 | expectedError: nil, 74 | }, 75 | { 76 | description: "Valid lowercase copyright statement should pass", 77 | fileContents: "copyright (c) 2022 hashicorp, inc.", 78 | caseSensitive: false, 79 | expectedValid: true, 80 | expectedError: nil, 81 | }, 82 | { 83 | description: "valid uppercase copyright statement should pass", 84 | fileContents: "COPYRIGHT (C) 2022 HASHICORP, INC.", 85 | caseSensitive: false, 86 | expectedValid: true, 87 | expectedError: nil, 88 | }, 89 | { 90 | description: "Valid lowercase copyright statement with case sensitivity on should fail", 91 | fileContents: "copyright (c) 2022 hashicorp, inc.", 92 | caseSensitive: true, 93 | expectedValid: false, 94 | expectedError: nil, 95 | }, 96 | { 97 | description: "valid uppercase copyright statement with case sensitivity on should fail", 98 | fileContents: "COPYRIGHT (C) 2022 HASHICORP, INC.", 99 | caseSensitive: true, 100 | expectedValid: false, 101 | expectedError: nil, 102 | }, 103 | { 104 | description: "valid copyright statement on document that has the copyright word elsewhere should pass", 105 | fileContents: `Copyright (c) 2022 HashiCorp, Inc. 106 | 107 | Apache License 108 | Version 2.0, January 2004 109 | http://www.apache.org/licenses/ 110 | 111 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 112 | 113 | 1. Definitions. 114 | 115 | "License" shall mean the terms and conditions for use, reproduction, 116 | and distribution as defined by Sections 1 through 9 of this document. 117 | 118 | "Licensor" shall mean the copyright owner or entity authorized by 119 | the copyright owner that is granting the License.`, 120 | caseSensitive: false, 121 | expectedValid: true, 122 | expectedError: nil, 123 | }, 124 | { 125 | description: "missing statement on document that has the copyright word elsewhere should fail", 126 | fileContents: `Apache License 127 | Version 2.0, January 2004 128 | http://www.apache.org/licenses/ 129 | 130 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 131 | 132 | 1. Definitions. 133 | 134 | "License" shall mean the terms and conditions for use, reproduction, 135 | and distribution as defined by Sections 1 through 9 of this document. 136 | 137 | "Licensor" shall mean the copyright owner or entity authorized by 138 | the copyright owner that is granting the License.`, 139 | caseSensitive: false, 140 | expectedValid: false, 141 | expectedError: nil, 142 | }, 143 | } 144 | 145 | for _, tt := range cases { 146 | t.Run(tt.description, func(t *testing.T) { 147 | f, _ := afero.TempFile(AppFs, tempDir, "") 148 | _ = afero.WriteFile(AppFs, f.Name(), []byte(tt.fileContents), 0644) 149 | // run test 150 | actualValid, err := HasMatchingCopyright(f.Name(), desiredCopyrightString, tt.caseSensitive) 151 | assert.ErrorIs(t, err, tt.expectedError, tt.description) 152 | assert.Equal(t, tt.expectedValid, actualValid, tt.description) 153 | }) 154 | } 155 | } 156 | 157 | // hasCopyright only tests if a copyright statement exists, not if the contents are valid 158 | func TestHasCopyright(t *testing.T) { 159 | AppFs := afero.NewOsFs() 160 | tempDir := t.TempDir() 161 | 162 | cases := []struct { 163 | description string 164 | fileContents string 165 | expectedValid bool 166 | expectedError error 167 | }{ 168 | { 169 | description: "Missing copyright statement should fail", 170 | fileContents: "", 171 | expectedValid: false, 172 | expectedError: nil, 173 | }, 174 | { 175 | description: "Missing copyright statement with language headers should fail", 176 | fileContents: "#!/bin/bash", 177 | expectedValid: false, 178 | expectedError: nil, 179 | }, 180 | { 181 | description: "Valid copyright statement should pass", 182 | fileContents: "Copyright (c) 2022 HashiCorp, Inc.", 183 | expectedValid: true, 184 | expectedError: nil, 185 | }, 186 | { 187 | description: "Valid copyright statement with language headers should pass", 188 | fileContents: "#!/bin/bash\nCopyright (c) 2022 HashiCorp, Inc.", 189 | expectedValid: true, 190 | expectedError: nil, 191 | }, 192 | { 193 | description: "Malformed copyright statement without symbol should pass", 194 | fileContents: "Copyright 2022 HashiCorp, Inc.", 195 | expectedValid: true, 196 | expectedError: nil, 197 | }, 198 | { 199 | description: "Malformed copyright statement without year should pass", 200 | fileContents: "Copyright (c) HashiCorp, Inc.", 201 | expectedValid: true, 202 | expectedError: nil, 203 | }, 204 | { 205 | description: "Malformed copyright statement without holder should pass", 206 | fileContents: "Copyright (c) 2022", 207 | expectedValid: true, 208 | expectedError: nil, 209 | }, 210 | { 211 | description: "Malformed copyright statement with year range should pass", 212 | fileContents: "Copyright 1995-2022 HashiCorp, Inc.", 213 | expectedValid: true, 214 | expectedError: nil, 215 | }, 216 | { 217 | description: "Valid lowercase copyright statement should pass", 218 | fileContents: "copyright 2022 hashicorp, inc.", 219 | expectedValid: true, 220 | expectedError: nil, 221 | }, 222 | { 223 | description: "valid uppercase copyright statement should pass", 224 | fileContents: "COPYRIGHT 2022 HASHICORP, INC.", 225 | expectedValid: true, 226 | expectedError: nil, 227 | }, 228 | { 229 | description: "valid copyright statement on document that has the copyright word elsewhere should pass", 230 | fileContents: `Copyright (c) 2022 HashiCorp, Inc. 231 | 232 | Apache License 233 | Version 2.0, January 2004 234 | http://www.apache.org/licenses/ 235 | 236 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 237 | 238 | 1. Definitions. 239 | 240 | "License" shall mean the terms and conditions for use, reproduction, 241 | and distribution as defined by Sections 1 through 9 of this document. 242 | 243 | "Licensor" shall mean the copyright owner or entity authorized by 244 | the copyright owner that is granting the License.`, 245 | expectedValid: true, 246 | expectedError: nil, 247 | }, 248 | { 249 | description: "missing statement on document that has the copyright word elsewhere should fail", 250 | fileContents: `Apache License 251 | Version 2.0, January 2004 252 | http://www.apache.org/licenses/ 253 | 254 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 255 | 256 | 1. Definitions. 257 | 258 | "License" shall mean the terms and conditions for use, reproduction, 259 | and distribution as defined by Sections 1 through 9 of this document. 260 | 261 | "Licensor" shall mean the copyright owner or entity authorized by 262 | the copyright owner that is granting the License.`, 263 | expectedValid: false, 264 | expectedError: nil, 265 | }, 266 | } 267 | 268 | for _, tt := range cases { 269 | t.Run(tt.description, func(t *testing.T) { 270 | f, _ := afero.TempFile(AppFs, tempDir, "") 271 | _ = afero.WriteFile(AppFs, f.Name(), []byte(tt.fileContents), 0644) 272 | // run test 273 | actualValid, err := HasCopyright(f.Name()) 274 | assert.ErrorIs(t, err, tt.expectedError, tt.description) 275 | assert.Equal(t, tt.expectedValid, actualValid, tt.description) 276 | }) 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /licensecheck/licensecheck.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package licensecheck 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "strings" 12 | 13 | "github.com/samber/lo" 14 | ) 15 | 16 | // EnsureCorrectName fixes a malformed license file name and returns the 17 | // new (corrected) file path 18 | // E.g., "license.txt" --> "LICENSE" 19 | func EnsureCorrectName(filePath string) (string, error) { 20 | dir, _ := filepath.Split(filePath) 21 | desiredPath := filepath.Join(dir, "LICENSE") 22 | if desiredPath != filePath { 23 | fmt.Printf("Found improperly named file \"%s\". Renaming to \"%s\"", filePath, desiredPath) 24 | err := os.Rename(filePath, filepath.Join(dir, "LICENSE")) 25 | if err != nil { 26 | return "", fmt.Errorf("Unable to rename file \"%s\". Full error context: %s", filePath, err) 27 | } 28 | } else { 29 | fmt.Printf("Validated file: %s\n", filePath) 30 | } 31 | 32 | return desiredPath, nil 33 | } 34 | 35 | // AddHeader prepends a given string to a file. It will automatically handle 36 | // newline characters 37 | func AddHeader(filePath string, header string) error { 38 | b, err := os.ReadFile(filePath) 39 | if err != nil { 40 | return err 41 | } 42 | b = append([]byte(header+"\n\n"), b...) 43 | return os.WriteFile(filePath, b, 0644) 44 | } 45 | 46 | // AddLicenseFile creates a file named "LICENSE" in the target directory 47 | // pre-populated with license text based on the SPDX Identifier you supply. 48 | // Returns the fully qualified path to the license file it created 49 | // 50 | // NOTE: this function will NOT add a copyright statement for you. You must 51 | // manually call AddHeader() afterward if you wish to have copyright headers 52 | func AddLicenseFile(dirPath string, spdxID string) (string, error) { 53 | template, exists := licenseTemplate[spdxID] 54 | if !exists { 55 | validOptions := strings.Join(lo.Keys(licenseTemplate), ", ") 56 | return "", fmt.Errorf("Failed to add license file, unknown SPDX license ID: %s. The following options are supported at this time: %s", spdxID, validOptions) 57 | } 58 | 59 | destinationPath, err := filepath.Abs(filepath.Join(dirPath, "LICENSE")) 60 | if err != nil { 61 | return "", err 62 | } 63 | 64 | err = os.WriteFile(destinationPath, []byte(template), 0644) 65 | if err != nil { 66 | return "", err 67 | } 68 | return destinationPath, nil 69 | } 70 | 71 | // FindLicenseFiles returns a list of filepaths for licenses in a given directory 72 | func FindLicenseFiles(dirPath string) ([]string, error) { 73 | // find all files in the supplied dirPath (1-level deep only) 74 | files, err := filepath.Glob(fmt.Sprintf("%s/*", dirPath)) 75 | 76 | if err != nil { 77 | return []string{}, err 78 | } 79 | 80 | // filter without case sensitivity for LICENSE, LICENSE.txt, and LICENSE.md 81 | r := regexp.MustCompile(`^(?i)(license.md|license.txt|license)$`) 82 | 83 | matches := lo.Filter(files, func(f string, _ int) bool { 84 | _, file := filepath.Split(f) 85 | return r.MatchString(file) 86 | }) 87 | 88 | return matches, nil 89 | } 90 | -------------------------------------------------------------------------------- /licensecheck/licensecheck_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package licensecheck 5 | 6 | import ( 7 | "path/filepath" 8 | "sort" 9 | "testing" 10 | 11 | "github.com/samber/lo" 12 | "github.com/spf13/afero" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func createTempFiles(t *testing.T, fileNames []string) (dirPath string, filePaths []string) { 17 | AppFs := afero.NewOsFs() 18 | tempDir := t.TempDir() 19 | // create file 20 | filePaths = lo.Map(fileNames, func(fileName string, i int) string { 21 | filePath := filepath.Join(tempDir, fileName) 22 | _ = afero.WriteFile(AppFs, filePath, []byte("Bob Loblaw's Law Blog"), 0644) 23 | return filePath 24 | }) 25 | 26 | return tempDir, filePaths 27 | } 28 | 29 | func TestEnsureCorrectName(t *testing.T) { 30 | AppFs := afero.NewOsFs() 31 | 32 | cases := []struct { 33 | description string 34 | filesToCreate []string 35 | }{ 36 | { 37 | description: "Correctly named file should be left alone", 38 | filesToCreate: []string{"LICENSE"}, 39 | }, 40 | { 41 | description: "License file with .txt extension should be renamed", 42 | filesToCreate: []string{"LICENSE.txt"}, 43 | }, 44 | { 45 | description: "License file with .md extension should be renamed", 46 | filesToCreate: []string{"LICENSE.md"}, 47 | }, 48 | { 49 | description: "Lowercase file name should be renamed", 50 | filesToCreate: []string{"license"}, 51 | }, 52 | { 53 | description: "Lowercase file name with .txt extension should be renamed", 54 | filesToCreate: []string{"license.txt"}, 55 | }, 56 | { 57 | description: "Lowercase file name with .md extension should be renamed", 58 | filesToCreate: []string{"license.md"}, 59 | }, 60 | { 61 | description: "Oddly cased file without extension should be renamed", 62 | filesToCreate: []string{"LiCeNsE"}, 63 | }, 64 | { 65 | description: "Oddly cased file with .txt extension should be renamed", 66 | filesToCreate: []string{"LiCeNsE.TxT"}, 67 | }, 68 | { 69 | description: "Oddly cased file with .md extension should be renamed", 70 | filesToCreate: []string{"LiCeNsE.Md"}, 71 | }, 72 | } 73 | 74 | for _, tt := range cases { 75 | t.Run(tt.description, func(t *testing.T) { 76 | tempDir, filePaths := createTempFiles(t, tt.filesToCreate) 77 | // run test 78 | _, err := EnsureCorrectName(filePaths[0]) 79 | assert.Nil(t, err) 80 | // validate file was renamed successfully 81 | fileExists, err := afero.Exists(AppFs, filepath.Join(tempDir, "LICENSE")) 82 | assert.True(t, fileExists) 83 | assert.Nil(t, err) 84 | }) 85 | } 86 | } 87 | 88 | func TestAddHeader(t *testing.T) { 89 | // stub 90 | t.Skip() 91 | } 92 | 93 | func TestAddLicenseFile(t *testing.T) { 94 | // stub 95 | t.Skip() 96 | } 97 | 98 | func sortSlice(input *[]string) { 99 | sort.Slice(*input, func(i, j int) bool { 100 | return (*input)[i] < (*input)[j] 101 | }) 102 | } 103 | 104 | func TestFindLicenseFiles(t *testing.T) { 105 | 106 | cases := []struct { 107 | description string 108 | input []string 109 | expectedOutput []string 110 | }{ 111 | { 112 | description: "Empty directory should have no matches", 113 | input: []string{}, 114 | expectedOutput: []string{}, 115 | }, 116 | { 117 | description: "Uppercase file without extension is matched", 118 | input: []string{"LICENSE"}, 119 | expectedOutput: []string{"LICENSE"}, 120 | }, 121 | { 122 | description: "Uppercase file with .txt extension is matched", 123 | input: []string{"LICENSE.txt"}, 124 | expectedOutput: []string{"LICENSE.txt"}, 125 | }, 126 | { 127 | description: "Uppercase file with .md extension is matched", 128 | input: []string{"LICENSE.md"}, 129 | expectedOutput: []string{"LICENSE.md"}, 130 | }, 131 | { 132 | description: "Multiple licenses with various extensions are matched", 133 | input: []string{"LICENSE", "LICENSE.txt", "LICENSE.md"}, 134 | expectedOutput: []string{"LICENSE", "LICENSE.txt", "LICENSE.md"}, 135 | }, 136 | { 137 | description: "Matches are case-insensitive", 138 | input: []string{"LiCenSe", "LICenSe.TXT", "liCense.mD"}, 139 | expectedOutput: []string{"LiCenSe", "LICenSe.TXT", "liCense.mD"}, 140 | }, 141 | { 142 | description: "Matches are case-insensitive", 143 | input: []string{"LiCenSe", "LICenSe.TXT", "liCense.mD"}, 144 | expectedOutput: []string{"LiCenSe", "LICenSe.TXT", "liCense.mD"}, 145 | }, 146 | { 147 | description: "Don't match files that are prefixed with other stuff", 148 | input: []string{"coollicense", "coollicense.txt", "coollicense.md"}, 149 | expectedOutput: []string{}, 150 | }, 151 | { 152 | description: "Don't match files with non-standard extensions", 153 | input: []string{"LICENSE.", "LICENSE.asdf", "LICENSE.csv", "LICENSE.txta", "LICENSE.mdx"}, 154 | expectedOutput: []string{}, 155 | }, 156 | { 157 | description: "Don't match directories", 158 | input: []string{"LICENSE", "license/blah.txt"}, 159 | expectedOutput: []string{"LICENSE"}, 160 | }, 161 | } 162 | 163 | for _, tt := range cases { 164 | t.Run(tt.description, func(t *testing.T) { 165 | tempDir, _ := createTempFiles(t, tt.input) 166 | // run test 167 | actualOutput, err := FindLicenseFiles(tempDir) 168 | // validate file was renamed successfully 169 | expectedOutputPaths := lo.Map(tt.expectedOutput, func(p string, _ int) string { 170 | return filepath.Join(tempDir, p) 171 | }) 172 | 173 | // sort both actual and expected output, as no guarantees are given on file ordering 174 | sortSlice(&expectedOutputPaths) 175 | sortSlice(&actualOutput) 176 | 177 | assert.Equal(t, expectedOutputPaths, actualOutput, tt.description) 178 | assert.Nil(t, err) 179 | }) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "github.com/hashicorp/copywrite/cmd" 8 | "github.com/hashicorp/go-hclog" 9 | ) 10 | 11 | func main() { 12 | appLogger := hclog.New(&hclog.LoggerOptions{ 13 | Name: "hc-copywrite", 14 | Level: hclog.LevelFromString("DEBUG"), 15 | Color: hclog.AutoColor, 16 | }) 17 | hclog.SetDefault(appLogger) 18 | cmd.Execute() 19 | } 20 | -------------------------------------------------------------------------------- /repodata/README.md: -------------------------------------------------------------------------------- 1 | # .repodata Parser 2 | 3 | The `repodata` module is designed to take parameters specifying how to filter a list of HashiCorp github repos. It does this by grabbing the repo data itself through a go package called [go-github](https://github.com/google/go-github) that allows the module to utilize the github cli to make the required api call. The filtering and compiling is then done within the module. 4 | 5 | An example invocation of the module might look like the following: 6 | 7 | ```sh 8 | copywrite report 9 | ``` 10 | 11 | Because there are no specific filters stated in this call, the default filters, namely Name, License and HTMLURL, will be used on the data 12 | 13 | An example invocation of the module with custom filters might look like the following: 14 | 15 | ```sh 16 | copywrite parse --fields Name,Language,License,UpdatedAt 17 | ``` 18 | 19 | ## Compatiblity 20 | 21 | The module currently supports data types of *string,*License and *Timestamp only. More can and will be implemented in the future. All data types are listed [here](https://github.com/google/go-github/blob/0b5813fe43cc374cacb2e7492861af7d12199377/github/repos.go#L270:~:text=type-,Repository,-struct%20%7B). The module also only supports a csv as an output file, but it is designed to allow for other output files to be implemented as well. 22 | -------------------------------------------------------------------------------- /repodata/repodata.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package repodata 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "reflect" 10 | "strings" 11 | 12 | "github.com/google/go-github/v45/github" 13 | gh "github.com/hashicorp/copywrite/github" 14 | "github.com/hashicorp/go-hclog" 15 | "github.com/mitchellh/mapstructure" 16 | "github.com/samber/lo" 17 | ) 18 | 19 | // GetRepos retrieves the repo data and places it into an array 20 | func GetRepos(githubOrganization string) ([]*github.Repository, error) { 21 | client := gh.NewGHClient().Raw() 22 | 23 | // list public repositories for org 24 | opt := &github.RepositoryListByOrgOptions{ 25 | ListOptions: github.ListOptions{PerPage: 100}, // 100 is the max page size 26 | Type: "public", 27 | } 28 | 29 | // pagination to always retrieve the exact number of repos and all metadata regarding them 30 | var allRepos []*github.Repository 31 | for { 32 | repos, current, err := client.Repositories.ListByOrg(context.Background(), githubOrganization, opt) 33 | if err != nil { 34 | hclog.L().Error(err.Error()) 35 | 36 | return []*github.Repository{}, err 37 | } 38 | 39 | // append to the master list of repos 40 | allRepos = append(allRepos, repos...) 41 | 42 | // check if no more pages before continuing pagination 43 | if current.NextPage == 0 { 44 | break 45 | } 46 | opt.Page = current.NextPage 47 | } 48 | return allRepos, nil 49 | } 50 | 51 | // FilterRepos returns a new array of repo structs that only has non-archived repos 52 | func FilterRepos(repos []*github.Repository) []*github.Repository { 53 | predicate := func(v *github.Repository, i int) bool { 54 | // Repo structs occasionally don't have the `Archived` key set. In these 55 | // cases, default to including the repo as it is categorically not archived 56 | return v.Archived == nil || !*v.Archived 57 | } 58 | return lo.Filter(repos, predicate) 59 | } 60 | 61 | // Transform takes in an array of repo structs and transforms it into an array of repo maps with attributes as strings 62 | func Transform(repos []*github.Repository) ([]map[string]interface{}, error) { 63 | // place all the metaData types into the csvData array 64 | var structRepos []map[string]interface{} 65 | for _, repo := range repos { 66 | //turn the repo struct into a map and append 67 | repomap := map[string]interface{}{} 68 | err := mapstructure.Decode(repo, &repomap) 69 | if err != nil { 70 | return []map[string]interface{}{}, err 71 | } 72 | 73 | // Transform values into strings for easier parsing 74 | for _, value := range lo.Keys(repomap) { 75 | //type assertion to index into the map and deference pointer value 76 | pointer := repomap[value] 77 | data := "" 78 | 79 | //pointer will never be nil, but the underlying value may be 80 | if !reflect.ValueOf(pointer).IsNil() { 81 | switch pointer := pointer.(type) { 82 | case *string: 83 | data = *pointer 84 | case *github.License: 85 | data = *pointer.Key 86 | case *github.Timestamp: // time will never be nil 87 | data = pointer.Time.String() 88 | default: 89 | } 90 | } 91 | repomap[value] = data 92 | } 93 | structRepos = append(structRepos, repomap) 94 | } 95 | 96 | return structRepos, nil 97 | } 98 | 99 | // ValidateInputFields takes the module input flag string, splits it by comma, and then checks to make sure each data type exists in the Repository struct 100 | func ValidateInputFields(fields string) ([]string, error) { 101 | //split by comma and trim whitespace 102 | values := strings.Split(fields, ",") 103 | for i := range values { 104 | values[i] = strings.TrimSpace(values[i]) 105 | } 106 | 107 | // convert to map 108 | base := new(github.Repository) 109 | repomap := map[string]interface{}{} 110 | err := mapstructure.Decode(base, &repomap) 111 | if err != nil { 112 | return []string{}, err 113 | } 114 | 115 | for _, value := range values { 116 | //make sure the data type exists in the struct 117 | _, exist := repomap[value] 118 | if !exist { 119 | hclog.L().Error("Data type does not exist in repository struct", "type", value) 120 | return []string{}, errors.New("Data type " + value + " does not exist in repository struct") 121 | } 122 | 123 | // if the data type is not currently supported 124 | switch repomap[value].(type) { 125 | case *string: 126 | case *github.License: 127 | case *github.Timestamp: 128 | default: 129 | return []string{}, errors.New("Data type " + value + " is currently not supported") 130 | } 131 | } 132 | 133 | return values, nil 134 | } 135 | -------------------------------------------------------------------------------- /repodata/repodata_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package repodata 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/google/go-github/v45/github" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | // to help with making archived and non archived repos for testing 14 | func makeArchivedRepo(flag bool) *github.Repository { 15 | repo := new(github.Repository) 16 | repo.Archived = &flag 17 | return repo 18 | } 19 | 20 | func makeNilArchivedRepo() *github.Repository { 21 | repo := new(github.Repository) 22 | repo.Archived = nil 23 | return repo 24 | } 25 | 26 | func TestFilterRepos(t *testing.T) { 27 | 28 | cases := []struct { 29 | description string 30 | actualresult []*github.Repository 31 | expectedresult []*github.Repository 32 | }{ 33 | { 34 | description: "archived repo should be removed", 35 | actualresult: FilterRepos([]*github.Repository{makeArchivedRepo(true)}), 36 | expectedresult: []*github.Repository{}, 37 | }, 38 | { 39 | description: "non archived repo should still remain", 40 | actualresult: FilterRepos([]*github.Repository{makeArchivedRepo(false)}), 41 | expectedresult: []*github.Repository{makeArchivedRepo(false)}, 42 | }, 43 | { 44 | description: "archived repo should be gone, non archived repo should stay", 45 | actualresult: FilterRepos([]*github.Repository{makeArchivedRepo(true), makeArchivedRepo(false)}), 46 | expectedresult: []*github.Repository{makeArchivedRepo(false)}, 47 | }, 48 | { 49 | description: "repo struct missing the archived key should still remain", 50 | actualresult: FilterRepos([]*github.Repository{makeNilArchivedRepo()}), 51 | expectedresult: []*github.Repository{makeNilArchivedRepo()}, 52 | }, 53 | } 54 | 55 | for _, tt := range cases { 56 | t.Run(tt.description, func(t *testing.T) { 57 | assert.Equal(t, tt.expectedresult, tt.actualresult, tt.description) 58 | }) 59 | } 60 | 61 | } 62 | 63 | func TestValidateInputFields(t *testing.T) { 64 | cases := []struct { 65 | description string 66 | inputString string 67 | expectedresult []string 68 | }{ 69 | { 70 | description: "default flag values all exist", 71 | inputString: "Name, HTMLURL, License, CreatedAt", 72 | expectedresult: []string{"Name", "HTMLURL", "License", "CreatedAt"}, 73 | }, 74 | } 75 | 76 | for _, tt := range cases { 77 | t.Run(tt.description, func(t *testing.T) { 78 | actualResult, err := ValidateInputFields(tt.inputString) 79 | assert.Equal(t, tt.expectedresult, actualResult, tt.description) 80 | assert.Nil(t, err) 81 | }) 82 | } 83 | 84 | // test errors 85 | errorCases := []struct { 86 | description string 87 | inputString string 88 | expectedresult string 89 | }{ 90 | { 91 | description: "data type does not exist in struct", 92 | inputString: "Name, HTMLURL, License, Dave", 93 | expectedresult: "Data type Dave does not exist in repository struct", 94 | }, 95 | { 96 | description: "data type isn't supported", 97 | inputString: "Name, HTMLURL, License, ForksCount", 98 | expectedresult: "Data type ForksCount is currently not supported", 99 | }, 100 | } 101 | 102 | for _, tt := range errorCases { 103 | t.Run(tt.description, func(t *testing.T) { 104 | actualResult, err := ValidateInputFields(tt.inputString) 105 | assert.Equal(t, tt.expectedresult, err.Error(), tt.description) 106 | assert.Equal(t, []string{}, actualResult, "should return empty after error") 107 | }) 108 | } 109 | } 110 | --------------------------------------------------------------------------------