diff --git a/.bandit b/.bandit new file mode 100644 index 000000000..eb4ecda52 --- /dev/null +++ b/.bandit @@ -0,0 +1,3 @@ +[bandit] +# subprocess is used intentionally in CLI wrappers and linter integrations. +skips = B404,B603 diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index cb72013d0..b6359ea49 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -2,7 +2,6 @@ self-hosted-runner: # Labels of self-hosted runner in array of strings. labels: - ubuntu-x64 - - macOS - ubuntu-latest # Configuration variables in array of strings defined in your repository or # organization. `null` means disabling configuration variables check. diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index 4218fd9bb..ee6032858 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -35,7 +35,7 @@ jobs: matrix: linter-version: [Snapshots, Latest] # TODO(Tyler): Re-add Windows runners. - os: [ubuntu-latest, macOS] + os: [ubuntu-latest, macos-latest] steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -81,12 +81,12 @@ jobs: fail-fast: false matrix: linter-version: [Snapshots, Latest] - os: [ubuntu-latest, macOS] + os: [ubuntu-latest, macos-latest] include: # Normalize the filenames as inputs for ease of parsing - os: ubuntu-latest results-file: ubuntu-latest - - os: macOS + - os: macos-latest results-file: macos-latest outputs: plugin-version: ${{ steps.get-release.outputs.tag }} @@ -216,12 +216,12 @@ jobs: fail-fast: false matrix: # TODO(Tyler): Re-add Windows runners. - os: [ubuntu-latest, macOS] + os: [ubuntu-latest, macos-latest] include: # Normalize the filenames as inputs for ease of parsing - os: ubuntu-latest results-file: ubuntu-latest - - os: macOS + - os: macos-latest results-file: macos-latest # - os: windows-latest # results-file: windows-latest diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index e9a3739d1..e281f3467 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -126,14 +126,14 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macOS] + os: [ubuntu-latest, macos-latest] steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # TODO(Tyler): Remove this once the cache has stabilized - name: Delete cache (mac only) - if: matrix.os == 'macOS' + if: matrix.os == 'macos-latest' # For now, avoid deleting cache on pull request changes to nightly. This improves PR experience. run: | if [ -d "${TMPDIR:-/tmp}/plugins_testing_download_cache" ] @@ -183,7 +183,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macOS] + os: [ubuntu-latest, macos-latest] steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/README.md b/README.md index e55a36f21..cf76f0ac3 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ trunk check enable {linter} | Dart | [dart] | | Docker | [hadolint], [checkov] | | Dotenv | [dotenv-linter] | -| GitHub | [actionlint], [zizmor] | +| GitHub | [actionlint], [pinact], [zizmor] | | Go | [gofmt], [gofumpt], [goimports], [gokart], [golangci-lint], [golines], [semgrep] | | GraphQL | [graphql-schema-linter], [prettier] | | HAML | [haml-lint] | @@ -81,7 +81,7 @@ trunk check enable {linter} | Ruby | [brakeman], [rubocop], [rufo], [semgrep], [standardrb] | | Rust | [clippy], [rustfmt] | | Scala | [scalafmt] | -| Security | [checkov], [dustilock], [grype], [nancy], [osv-scanner], [snyk], [tfsec], [trivy], [trufflehog], [terrascan], [zizmor] | +| Security | [checkov], [dustilock], [grype], [nancy], [osv-scanner], [pinact], [snyk], [tfsec], [trivy], [trufflehog], [terrascan], [zizmor] | | SQL | [sqlfluff], [sqlfmt], [sql-formatter], [squawk] | | SVG | [svgo] | | Swift | [stringslint], [swiftlint], [swiftformat] | @@ -151,6 +151,7 @@ trunk check enable {linter} [oxipng]: https://github.com/shssoichiro/oxipng#readme [perlcritic]: https://metacpan.org/pod/Perl::Critic [perltidy]: https://metacpan.org/dist/Perl-Tidy/view/bin/perltidy +[pinact]: https://github.com/suzuki-shunsuke/pinact#readme [php-cs-fixer]: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer#readme [phpstan]: https://phpstan.org/ [pmd]: https://pmd.github.io/ diff --git a/linters/pinact/pinact.test.ts b/linters/pinact/pinact.test.ts new file mode 100644 index 000000000..8318ebf84 --- /dev/null +++ b/linters/pinact/pinact.test.ts @@ -0,0 +1,89 @@ +import * as fs from "fs"; +import * as path from "path"; +import { customLinterCheckTest } from "tests"; +import { TrunkLintDriver } from "tests/driver"; +import { TEST_DATA } from "tests/utils"; + +const moveWorkflowFile = + (filename: string, disableGhAuth = false) => + async (driver: TrunkLintDriver) => { + if (disableGhAuth) { + process.env.PINACT_DISABLE_GH_AUTH = "1"; + } else { + delete process.env.PINACT_DISABLE_GH_AUTH; + } + driver.moveFile(path.join(TEST_DATA, filename), path.join(".github/workflows", filename)); + await driver.gitDriver?.add(".").commit("moved"); + }; + +const moveWorkflowFiles = + (sourceDir = TEST_DATA) => + async (driver: TrunkLintDriver) => { + delete process.env.PINACT_DISABLE_GH_AUTH; + + fs.readdirSync(path.resolve(driver.getSandbox(), sourceDir), { withFileTypes: true }) + .filter((file) => file.isFile()) + .forEach((file) => { + driver.moveFile(path.join(sourceDir, file.name), path.join(".github/workflows", file.name)); + }); + await driver.gitDriver?.add(".").commit("moved"); + }; + +const enablePinactCommand = + (command: string, preCheck?: (driver: TrunkLintDriver) => Promise) => + async (driver: TrunkLintDriver) => { + delete process.env.PINACT_DISABLE_GH_AUTH; + + const trunkYamlPath = ".trunk/trunk.yaml"; + const currentContents = driver.readFile(trunkYamlPath); + const pinactRegex = /- pinact@(.+)\n/; + + driver.writeFile( + trunkYamlPath, + currentContents.replace(pinactRegex, `- pinact@$1:\n commands: [${command}]\n`), + ); + + if (preCheck) { + await preCheck(driver); + } + }; + +const skipIfMissingGitHubToken = () => { + if (!process.env.GH_TOKEN && !process.env.GITHUB_TOKEN && !process.env.PINACT_GITHUB_TOKEN) { + console.log( + "Skipping pinact online audit test because GH_TOKEN, GITHUB_TOKEN, and PINACT_GITHUB_TOKEN are not set.", + ); + return true; + } + return false; +}; + +customLinterCheckTest({ + linterName: "pinact", + testName: "missing_version_comment", + args: ".github", + preCheck: moveWorkflowFile("missing_version_comment.in.yaml", true), +}); + +customLinterCheckTest({ + linterName: "pinact", + testName: "unpinned", + args: ".github", + preCheck: moveWorkflowFile("unpinned.in.yaml", true), +}); + +customLinterCheckTest({ + linterName: "pinact", + testName: "version_comment", + args: ".github", + preCheck: moveWorkflowFiles(path.join(TEST_DATA, "online")), + skipTestIf: skipIfMissingGitHubToken, +}); + +customLinterCheckTest({ + linterName: "pinact", + testName: "upgrade", + args: ".github", + preCheck: enablePinactCommand("upgrade", moveWorkflowFile("unpinned.in.yaml")), + skipTestIf: skipIfMissingGitHubToken, +}); diff --git a/linters/pinact/pinact_run.py b/linters/pinact/pinact_run.py new file mode 100644 index 000000000..75c77a545 --- /dev/null +++ b/linters/pinact/pinact_run.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +import os +import shutil +import subprocess +import sys +from pathlib import Path + + +def resolve_executable(name: str) -> str | None: + return shutil.which(name) + + +def require_executable(name: str) -> str: + path = resolve_executable(name) + if path is None: + print(f"{name} not found in PATH", file=sys.stderr) + raise SystemExit(127) + return path + + +def validate_targets(paths: list[str]) -> list[str]: + validated: list[str] = [] + for path in paths: + target = Path(path) + if not target.exists(): + print(f"target not found: {path}", file=sys.stderr) + raise SystemExit(2) + validated.append(str(target.resolve())) + return validated + + +def gh_auth_token() -> str | None: + gh = resolve_executable("gh") + if gh is None: + return None + + try: + result = subprocess.run( + [gh, "auth", "token"], + shell=False, + check=False, + capture_output=True, + text=True, + ) + except OSError: + return None + + if result.returncode != 0: + return None + + token = result.stdout.strip() + return token or None + + +def configure_token_env() -> None: + github_token = os.environ.get("GITHUB_TOKEN") + pinact_github_token = os.environ.get("PINACT_GITHUB_TOKEN") + gh_token = os.environ.get("GH_TOKEN") + + if not github_token and gh_token: + os.environ["GITHUB_TOKEN"] = gh_token + github_token = gh_token + + if not pinact_github_token and github_token: + os.environ["PINACT_GITHUB_TOKEN"] = github_token + + if not os.environ.get("GITHUB_TOKEN") and not os.environ.get("PINACT_GITHUB_TOKEN"): + if not os.environ.get("PINACT_DISABLE_GH_AUTH"): + token = gh_auth_token() + if token: + os.environ["GITHUB_TOKEN"] = token + os.environ["PINACT_GITHUB_TOKEN"] = token + + +def has_github_token() -> bool: + return bool(os.environ.get("GITHUB_TOKEN") or os.environ.get("PINACT_GITHUB_TOKEN")) + + +def build_pinact_args(mode: str) -> list[str]: + args = ["pinact", "run", "-format", "sarif"] + if mode == "upgrade": + # -update bumps to latest semver; SARIF suggestions only (Trunk applies fixes). + args.extend(["-update"]) + return args + + # SARIF output implies -fix=false; let Trunk apply fixes from SARIF suggestions. + if os.environ.get("PINACT_DISABLE_GH_AUTH"): + args.append("-no-api") + elif has_github_token(): + args.append("-verify-comment") + else: + args.append("-no-api") + return args + + +def run_pinact(mode: str, targets: list[str]) -> int: + pinact = require_executable("pinact") + return subprocess.run( + [pinact, *build_pinact_args(mode)[1:], *validate_targets(targets)], + shell=False, + check=False, + ).returncode + + +def main() -> int: + configure_token_env() + + argv = sys.argv[1:] + mode = "lint" + if argv and argv[0] == "--upgrade": + mode = "upgrade" + argv = argv[1:] + + return run_pinact(mode, argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/linters/pinact/plugin.yaml b/linters/pinact/plugin.yaml new file mode 100644 index 000000000..1392c5e1c --- /dev/null +++ b/linters/pinact/plugin.yaml @@ -0,0 +1,76 @@ +version: 0.1 +downloads: + - name: pinact + downloads: + - os: + linux: linux + macos: darwin + cpu: + x86_64: amd64 + arm_64: arm64 + url: https://github.com/suzuki-shunsuke/pinact/releases/download/v${version}/pinact_${os}_${cpu}.tar.gz + - os: windows + cpu: + x86_64: amd64 + arm_64: arm64 + url: https://github.com/suzuki-shunsuke/pinact/releases/download/v${version}/pinact_windows_${cpu}.zip +tools: + definitions: + - name: pinact + download: pinact + shims: [pinact] + known_good_version: 4.0.0 +lint: + definitions: + - name: pinact + files: [github-workflow, github-actions] + tools: [pinact] + runtime: python + hold_the_line: false + description: Pin and verify GitHub Actions and reusable workflows + commands: + - name: lint + output: sarif + run: python3 ${plugin}/linters/pinact/pinact_run.py ${target} + success_codes: [0, 1, 2, 3] + batch: true + cache_results: true + is_security: true + read_output_from: stdout + disable_upstream: true + - name: upgrade + output: sarif + run: python3 ${plugin}/linters/pinact/pinact_run.py --upgrade ${target} + success_codes: [0, 1, 2, 3] + batch: true + cache_results: true + is_security: true + read_output_from: stdout + disable_upstream: true + enabled: false + direct_configs: + - .pinact.yml + - .pinact.yaml + - .github/pinact.yml + - .github/pinact.yaml + suggest_if: files_present + environment: + - name: GITHUB_TOKEN + value: ${env.GITHUB_TOKEN} + optional: true + - name: PINACT_GITHUB_TOKEN + value: ${env.PINACT_GITHUB_TOKEN} + optional: true + - name: GH_TOKEN + value: ${env.GH_TOKEN} + optional: true + - name: PINACT_DISABLE_GH_AUTH + value: ${env.PINACT_DISABLE_GH_AUTH} + optional: true + - name: PATH + list: ["${runtime}", "${linter}", "${env.PATH}"] + issue_url_format: https://github.com/suzuki-shunsuke/pinact/blob/main/docs/codes/ + known_good_version: 4.0.0 + version_command: + parse_regex: ${semver} + run: pinact version diff --git a/linters/pinact/test_data/missing_version_comment.in.yaml b/linters/pinact/test_data/missing_version_comment.in.yaml new file mode 100644 index 000000000..12dd56e71 --- /dev/null +++ b/linters/pinact/test_data/missing_version_comment.in.yaml @@ -0,0 +1,13 @@ +name: missing version comment + +on: + push: + +permissions: {} + +jobs: + test: + permissions: {} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd diff --git a/linters/pinact/test_data/online/mismatched_version_comment.in.yaml b/linters/pinact/test_data/online/mismatched_version_comment.in.yaml new file mode 100644 index 000000000..2efe65773 --- /dev/null +++ b/linters/pinact/test_data/online/mismatched_version_comment.in.yaml @@ -0,0 +1,15 @@ +name: mismatched version comment + +on: + push: + +permissions: {} + +jobs: + test: + permissions: {} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.2.2 + with: + persist-credentials: false diff --git a/linters/pinact/test_data/pinact_v4.0.0_missing_version_comment.check.shot b/linters/pinact/test_data/pinact_v4.0.0_missing_version_comment.check.shot new file mode 100644 index 000000000..122b4546e --- /dev/null +++ b/linters/pinact/test_data/pinact_v4.0.0_missing_version_comment.check.shot @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing linter pinact test missing_version_comment 1`] = ` +{ + "issues": [ + { + "code": "parse-error", + "column": "1", + "file": ".github/workflows/missing_version_comment.in.yaml", + "isSecurity": true, + "issueClass": "ISSUE_CLASS_NEW", + "issueUrl": "https://github.com/suzuki-shunsuke/pinact/blob/main/docs/codes/", + "level": "LEVEL_HIGH", + "line": "13", + "linter": "pinact", + "message": "failed to handle a line: SHA-pinned action requires a version comment for verifiability", + "targetType": "github-workflow", + }, + ], + "lintActions": [ + { + "command": "lint", + "fileGroupName": "github-workflow", + "linter": "pinact", + "paths": [ + ".github/workflows/missing_version_comment.in.yaml", + ], + "verb": "TRUNK_VERB_CHECK", + }, + ], + "taskFailures": [], + "unformattedFiles": [], +} +`; diff --git a/linters/pinact/test_data/pinact_v4.0.0_unpinned.check.shot b/linters/pinact/test_data/pinact_v4.0.0_unpinned.check.shot new file mode 100644 index 000000000..a9d715d90 --- /dev/null +++ b/linters/pinact/test_data/pinact_v4.0.0_unpinned.check.shot @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing linter pinact test unpinned 1`] = ` +{ + "issues": [ + { + "code": "parse-error", + "column": "1", + "file": ".github/workflows/unpinned.in.yaml", + "isSecurity": true, + "issueClass": "ISSUE_CLASS_NEW", + "issueUrl": "https://github.com/suzuki-shunsuke/pinact/blob/main/docs/codes/", + "level": "LEVEL_HIGH", + "line": "13", + "linter": "pinact", + "message": "failed to handle a line: action can't be pinned", + "targetType": "github-workflow", + }, + ], + "lintActions": [ + { + "command": "lint", + "fileGroupName": "github-workflow", + "linter": "pinact", + "paths": [ + ".github/workflows/unpinned.in.yaml", + ], + "verb": "TRUNK_VERB_CHECK", + }, + ], + "taskFailures": [], + "unformattedFiles": [], +} +`; diff --git a/linters/pinact/test_data/pinact_v4.0.0_upgrade.check.shot b/linters/pinact/test_data/pinact_v4.0.0_upgrade.check.shot new file mode 100644 index 000000000..974abf57c --- /dev/null +++ b/linters/pinact/test_data/pinact_v4.0.0_upgrade.check.shot @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing linter pinact test upgrade 1`] = ` +{ + "issues": [ + { + "autofixOptions": [ + { + "replacements": [ + { + "filePath": ".github/workflows/unpinned.in.yaml", + "offset": "118", + "replacementText": " - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3", + }, + ], + }, + ], + "code": "unpinned-action", + "column": "1", + "file": ".github/workflows/unpinned.in.yaml", + "isSecurity": true, + "issueClass": "ISSUE_CLASS_NEW", + "issueUrl": "https://github.com/suzuki-shunsuke/pinact/blob/main/docs/codes/", + "level": "LEVEL_HIGH", + "line": "13", + "linter": "pinact", + "message": "Action should be pinned: - uses: actions/checkout@v4 -> - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3", + "targetType": "github-workflow", + }, + ], + "lintActions": [ + { + "command": "upgrade", + "fileGroupName": "github-workflow", + "linter": "pinact", + "paths": [ + ".github/workflows/unpinned.in.yaml", + ], + "verb": "TRUNK_VERB_CHECK", + }, + ], + "taskFailures": [], + "unformattedFiles": [], +} +`; diff --git a/linters/pinact/test_data/pinact_v4.0.0_version_comment.check.shot b/linters/pinact/test_data/pinact_v4.0.0_version_comment.check.shot new file mode 100644 index 000000000..b38c4090c --- /dev/null +++ b/linters/pinact/test_data/pinact_v4.0.0_version_comment.check.shot @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing linter pinact test version_comment 1`] = ` +{ + "issues": [ + { + "code": "parse-error", + "column": "1", + "file": ".github/workflows/mismatched_version_comment.in.yaml", + "isSecurity": true, + "issueClass": "ISSUE_CLASS_NEW", + "issueUrl": "https://github.com/suzuki-shunsuke/pinact/blob/main/docs/codes/", + "level": "LEVEL_HIGH", + "line": "13", + "linter": "pinact", + "message": "failed to handle a line: action_version must be equal to commit_hash_of_version_annotation", + "targetType": "github-workflow", + }, + ], + "lintActions": [ + { + "command": "lint", + "fileGroupName": "github-workflow", + "linter": "pinact", + "paths": [ + ".github/workflows/mismatched_version_comment.in.yaml", + ], + "verb": "TRUNK_VERB_CHECK", + }, + ], + "taskFailures": [], + "unformattedFiles": [], +} +`; diff --git a/linters/pinact/test_data/unpinned.in.yaml b/linters/pinact/test_data/unpinned.in.yaml new file mode 100644 index 000000000..907121c31 --- /dev/null +++ b/linters/pinact/test_data/unpinned.in.yaml @@ -0,0 +1,13 @@ +name: unpinned + +on: + push: + +permissions: {} + +jobs: + test: + permissions: {} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 diff --git a/linters/plugin.yaml b/linters/plugin.yaml index 32c0d21b8..4f60f7542 100644 --- a/linters/plugin.yaml +++ b/linters/plugin.yaml @@ -259,6 +259,13 @@ lint: comments: - hash + - name: github-actions + regexes: + - .github/actions/.+\.yaml + - .github/actions/.+\.yml + comments: + - hash + - name: go extensions: - go diff --git a/tests/repo_tests/config_check.test.ts b/tests/repo_tests/config_check.test.ts index ef7ff295f..561771445 100644 --- a/tests/repo_tests/config_check.test.ts +++ b/tests/repo_tests/config_check.test.ts @@ -158,6 +158,7 @@ describe("Global config health check", () => { "nixpkgs-fmt", "osv-scanner", "oxipng", + "pinact", "prettier", "ruff", "rustfmt",