diff --git a/.github/workflows/static-analysis-report.lock.yml b/.github/workflows/static-analysis-report.lock.yml index 6dc7dacc7eb..8d2045a6d6d 100644 --- a/.github/workflows/static-analysis-report.lock.yml +++ b/.github/workflows/static-analysis-report.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"fff0fc640fe0daee152d28223cb1d5279f2d18eabf71dc42cba91f2b9e7580c3","body_hash":"30636ec30879016c7250a8383a69c71aa69640489b71a1a756da26a0bd12e7b7","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.216"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"fd68d2283b46af2e903854ecfca4bfd156dbd95f1a1949a0573b77a9eaad1c9a","body_hash":"b44da88272d6a0edbc2011e51c7fbb65d4c06f400078ecd2a51bbbe13a54ff39","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.216"}} # gh-aw-manifest: {"version":1,"secrets":["ANTHROPIC_API_KEY","COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"b7ad1dad31e06c5925ef5d2fc7ad053ef454303e","version":"v7.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"docker/build-push-action","sha":"53b7df96c91f9c12dcc8a07bcb9ccacbed38856a","version":"v7.3.0"},{"repo":"docker/setup-buildx-action","sha":"bb05f3f5519dd87d3ba754cc423b652a5edd6d2c","version":"v4.2.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.38","digest":"sha256:cb928eb62d9139a013c2d278dab19af232d35a2d83dca71a3d98eb431f786243","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.38@sha256:cb928eb62d9139a013c2d278dab19af232d35a2d83dca71a3d98eb431f786243"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.38","digest":"sha256:cd6145620d96acee46e1ede25180a13aa36002467e663db0caa453a8bc8eb60c","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.38@sha256:cd6145620d96acee46e1ede25180a13aa36002467e663db0caa453a8bc8eb60c"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.38","digest":"sha256:c30c5319da37505d42f95cb3faa2cfa55e794ccb5cc805dbd9201410d1ac2a3e","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.38@sha256:c30c5319da37505d42f95cb3faa2cfa55e794ccb5cc805dbd9201410d1ac2a3e"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.38","digest":"sha256:6c19094d95aad5f9f128ad5e583f0f2b894b158aa66c3b86dd9bcc90970a2917","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.38@sha256:6c19094d95aad5f9f128ad5e583f0f2b894b158aa66c3b86dd9bcc90970a2917"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.3","digest":"sha256:3c744710ea275cd5ee65db92a1099e0d980754bd9fafda9ce67704c67004dc83","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.3@sha256:3c744710ea275cd5ee65db92a1099e0d980754bd9fafda9ce67704c67004dc83"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.6.0","digest":"sha256:2b0c48b070f61e9d3969269ead600f62d00fb237b60ac849ef3d166ee7de9ad3","pinned_image":"ghcr.io/github/github-mcp-server:v1.6.0@sha256:2b0c48b070f61e9d3969269ead600f62d00fb237b60ac849ef3d166ee7de9ad3"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -23,7 +23,7 @@ # # For more information: https://github.github.com/gh-aw/introduction/overview/ # -# Scans agentic workflows daily with zizmor, poutine, actionlint, runner-guard, syft, and grype +# Scans agentic workflows daily with zizmor, poutine, actionlint, runner-guard, syft, grype, and yamllint # # Resolved workflow manifest: # Imports: @@ -565,11 +565,11 @@ jobs: make build "$GITHUB_WORKSPACE/gh-aw" --version - name: Pull static analysis Docker images - run: "set -e\necho \"Pulling Docker images for static analysis tools...\"\n\n# Pull zizmor Docker image\necho \"Pulling zizmor image...\"\ndocker pull ghcr.io/zizmorcore/zizmor:latest\n\n# Pull poutine Docker image\necho \"Pulling poutine image...\"\ndocker pull ghcr.io/boostsecurityio/poutine:latest\n\n# Pull runner-guard Docker image\necho \"Pulling runner-guard image...\"\ndocker pull ghcr.io/vigilant-llc/runner-guard:latest\n\n# Pull grype Docker image\necho \"Pulling grype image...\"\ndocker pull anchore/grype:latest\n\n# Pull syft Docker image\necho \"Pulling syft image...\"\ndocker pull anchore/syft:latest\n\necho \"All static analysis Docker images pulled successfully\"\n" + run: "set -e\necho \"Pulling Docker images for static analysis tools...\"\n\n# Pull zizmor Docker image\necho \"Pulling zizmor image...\"\ndocker pull ghcr.io/zizmorcore/zizmor:latest\n\n# Pull poutine Docker image\necho \"Pulling poutine image...\"\ndocker pull ghcr.io/boostsecurityio/poutine:latest\n\n# Pull runner-guard Docker image\necho \"Pulling runner-guard image...\"\ndocker pull ghcr.io/vigilant-llc/runner-guard:latest\n\n# Pull grype Docker image\necho \"Pulling grype image...\"\ndocker pull anchore/grype:latest\n\n# Pull syft Docker image\necho \"Pulling syft image...\"\ndocker pull anchore/syft:latest\n\n# Pull yamllint Docker image\necho \"Pulling yamllint image...\"\ndocker pull pipelinecomponents/yamllint:latest\n\necho \"All static analysis Docker images pulled successfully\"\n" - name: Verify static analysis tools - run: "set -e\necho \"Verifying static analysis tools are available...\"\n\n# Verify zizmor\necho \"Testing zizmor...\"\ndocker run --rm ghcr.io/zizmorcore/zizmor:latest --version || echo \"Warning: zizmor version check failed\"\n\n# Verify poutine\necho \"Testing poutine...\"\ndocker run --rm ghcr.io/boostsecurityio/poutine:latest --version || echo \"Warning: poutine version check failed\"\n\n# Verify runner-guard\necho \"Testing runner-guard...\"\ndocker run --rm ghcr.io/vigilant-llc/runner-guard:latest --version || echo \"Warning: runner-guard version check failed\"\n\n# Verify grype\necho \"Testing grype...\"\ndocker run --rm anchore/grype:latest version || echo \"Warning: grype version check failed\"\n\n# Verify syft\necho \"Testing syft...\"\ndocker run --rm anchore/syft:latest version || echo \"Warning: syft version check failed\"\n\necho \"Static analysis tools verification complete\"\n" + run: "set -e\necho \"Verifying static analysis tools are available...\"\n\n# Verify zizmor\necho \"Testing zizmor...\"\ndocker run --rm ghcr.io/zizmorcore/zizmor:latest --version || echo \"Warning: zizmor version check failed\"\n\n# Verify poutine\necho \"Testing poutine...\"\ndocker run --rm ghcr.io/boostsecurityio/poutine:latest --version || echo \"Warning: poutine version check failed\"\n\n# Verify runner-guard\necho \"Testing runner-guard...\"\ndocker run --rm ghcr.io/vigilant-llc/runner-guard:latest --version || echo \"Warning: runner-guard version check failed\"\n\n# Verify grype\necho \"Testing grype...\"\ndocker run --rm anchore/grype:latest version || echo \"Warning: grype version check failed\"\n\n# Verify syft\necho \"Testing syft...\"\ndocker run --rm anchore/syft:latest version || echo \"Warning: syft version check failed\"\n\n# Verify yamllint\necho \"Testing yamllint...\"\ndocker run --rm pipelinecomponents/yamllint:latest --version || echo \"Warning: yamllint version check failed\"\n\necho \"Static analysis tools verification complete\"\n" - name: Run compile with security tools - run: "set -e\necho \"Running gh aw compile with security tools to download Docker images...\"\n\n# Run compile with all security scanner flags to download Docker images\n# Store the output in a file for inspection\n\"$GITHUB_WORKSPACE/gh-aw\" compile --zizmor --poutine --actionlint --runner-guard --syft --grype 2>&1 | tee /tmp/gh-aw/agent/compile-output.txt\n\necho \"Compile with security tools completed\"\necho \"Output saved to /tmp/gh-aw/agent/compile-output.txt\"" + run: "set -e\necho \"Running gh aw compile with security tools to download Docker images...\"\n\n# Run compile with all security scanner flags to download Docker images\n# Store the output in a file for inspection\n\"$GITHUB_WORKSPACE/gh-aw\" compile --zizmor --poutine --actionlint --runner-guard --syft --grype --yamllint 2>&1 | tee /tmp/gh-aw/agent/compile-output.txt\n\necho \"Compile with security tools completed\"\necho \"Output saved to /tmp/gh-aw/agent/compile-output.txt\"" - name: Configure Git credentials env: @@ -1644,7 +1644,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: WORKFLOW_NAME: "Static Analysis Report" - WORKFLOW_DESCRIPTION: "Scans agentic workflows daily with zizmor, poutine, actionlint, runner-guard, syft, and grype" + WORKFLOW_DESCRIPTION: "Scans agentic workflows daily with zizmor, poutine, actionlint, runner-guard, syft, grype, and yamllint" HAS_PATCH: ${{ needs.agent.outputs.has_patch }} with: script: | diff --git a/.github/workflows/static-analysis-report.md b/.github/workflows/static-analysis-report.md index 808a53b5ebc..6c809937ffa 100644 --- a/.github/workflows/static-analysis-report.md +++ b/.github/workflows/static-analysis-report.md @@ -1,6 +1,6 @@ --- emoji: "📊" -description: Scans agentic workflows daily with zizmor, poutine, actionlint, runner-guard, syft, and grype +description: Scans agentic workflows daily with zizmor, poutine, actionlint, runner-guard, syft, grype, and yamllint on: schedule: daily workflow_dispatch: @@ -63,6 +63,10 @@ steps: echo "Pulling syft image..." docker pull anchore/syft:latest + # Pull yamllint Docker image + echo "Pulling yamllint image..." + docker pull pipelinecomponents/yamllint:latest + echo "All static analysis Docker images pulled successfully" - name: Verify static analysis tools run: | @@ -89,6 +93,10 @@ steps: echo "Testing syft..." docker run --rm anchore/syft:latest version || echo "Warning: syft version check failed" + # Verify yamllint + echo "Testing yamllint..." + docker run --rm pipelinecomponents/yamllint:latest --version || echo "Warning: yamllint version check failed" + echo "Static analysis tools verification complete" - name: Run compile with security tools run: | @@ -97,7 +105,7 @@ steps: # Run compile with all security scanner flags to download Docker images # Store the output in a file for inspection - "$GITHUB_WORKSPACE/gh-aw" compile --zizmor --poutine --actionlint --runner-guard --syft --grype 2>&1 | tee /tmp/gh-aw/agent/compile-output.txt + "$GITHUB_WORKSPACE/gh-aw" compile --zizmor --poutine --actionlint --runner-guard --syft --grype --yamllint 2>&1 | tee /tmp/gh-aw/agent/compile-output.txt echo "Compile with security tools completed" echo "Output saved to /tmp/gh-aw/agent/compile-output.txt" @@ -109,7 +117,7 @@ sandbox: # Static Analysis Report -You are the Static Analysis Report Agent - an expert system that scans agentic workflows for security vulnerabilities, SBOM inventory data, and code quality issues using multiple static analysis tools: zizmor, poutine, actionlint, runner-guard, syft, and grype. +You are the Static Analysis Report Agent - an expert system that scans agentic workflows for security vulnerabilities, SBOM inventory data, and code quality issues using multiple static analysis tools: zizmor, poutine, actionlint, runner-guard, syft, grype, and yamllint. ## Mission @@ -129,22 +137,19 @@ Daily scan all agentic workflow files with static analysis tools to identify sec ### Phase 1: Analyze Static Analysis Output -The workflow has already compiled all workflows with static analysis tools (zizmor, poutine, actionlint, runner-guard, syft, grype) and saved the output to `/tmp/gh-aw/agent/compile-output.txt`. +The workflow has already compiled all workflows with static analysis tools (zizmor, poutine, actionlint, runner-guard, syft, grype, yamllint) and saved the output to `/tmp/gh-aw/agent/compile-output.txt`. 1. **Read Compilation Output**: - Read and parse the file `/tmp/gh-aw/agent/compile-output.txt` which contains the compilation output from all six static analysis tools. + Read the file `/tmp/gh-aw/agent/compile-output.txt`, which contains the human-readable compilation and static analysis output from all seven tools. - The output is JSON format with validation results for each workflow: - - workflow: Name of the workflow file - - valid: Boolean indicating if compilation was successful - - errors: Array of error objects with type, message, and optional line number - - warnings: Array of warning objects - - compiled_file: Path to the generated .lock.yml file - - security and lint findings from zizmor, poutine, actionlint, runner-guard, and grype (if any) - - SBOM inventory output from syft, including scanned images and package counts + The file includes: + - workflow compilation success/failure lines + - compiler validation errors and warnings + - findings emitted by zizmor, poutine, actionlint, runner-guard, syft, grype, and yamllint + - tool-specific messages, locations, remediation hints, and SBOM inventory details when available 2. **Parse and Extract Findings**: - - Parse the compilation output to extract findings from all six tools + - Parse the saved compile output to extract findings from all seven tools - Note which workflows have findings from each tool - Identify total number of issues by tool and severity - Extract specific error messages, locations, and recommendations @@ -186,6 +191,13 @@ Review the output from all six tools and cluster findings: - Location (file, line, column) - Suggestions for fixes +**Yamllint Output**: +- Extract YAML linting issues +- Parse finding details: + - Error/warning message + - Rule name + - Location (file, line, column) + **Syft Output**: - Extract SBOM inventory data from syft - Parse inventory details: @@ -202,10 +214,10 @@ Review the output from all six tools and cluster findings: - Severity - Affected image and workflow - Fixed version when available - #### 2.2 Cluster by Issue Type and Tool Group findings by: -- Tool (zizmor, poutine, actionlint, runner-guard, syft, grype) +Group findings by: +- Tool (zizmor, poutine, actionlint, runner-guard, syft, grype, yamllint) - Issue identifier/rule code - Severity level - Count occurrences of each issue type @@ -225,7 +237,7 @@ Use the cache memory folder `/tmp/gh-aw/cache-memory/` to build persistent knowl 1. **Create Security Scan Index**: - Save scan results to `/tmp/gh-aw/cache-memory/security-scans/.json` - - Include findings from all six tools (zizmor, poutine, actionlint, runner-guard, syft, grype) + - Include findings from all seven tools (zizmor, poutine, actionlint, runner-guard, syft, grype, yamllint) - Maintain an index of all scans in `/tmp/gh-aw/cache-memory/security-scans/index.json` 2. **Update Vulnerability Database**: @@ -317,7 +329,7 @@ Wrap long sections (>5 items, detailed lists, raw data) in `
5 items, detailed lists, raw data) in `
`. Accepts a branch name, tag, or commit SHA targeting the `github/gh-aw` repository. Branch and tag names are resolved to their full commit SHA at compile time, so the baked-in reference is immutable and reproducible. Useful for E2E-testing workflows compiled against a specific gh-aw revision. diff --git a/pkg/cli/README.md b/pkg/cli/README.md index 045cb7f57cf..434747c0f9c 100644 --- a/pkg/cli/README.md +++ b/pkg/cli/README.md @@ -214,7 +214,7 @@ All diagnostic output MUST go to `stderr` using `console` formatting helpers. St | `IsDockerImageAvailable` | `func(ctx context.Context, image string) bool` | Returns true if a Docker image is present locally | | `IsDockerImageDownloading` | `func(string) bool` | Returns true if an image pull is in progress | | `StartDockerImageDownload` | `func(ctx, image string) bool` | Begins a background image pull; returns false if already pulling | -| `CheckAndPrepareDockerImages` | `func(ctx, useZizmor, usePoutine, useActionlint, useRunnerGuard bool) error` | Pre-pulls security-scanner Docker images | +| `CheckAndPrepareDockerImages` | `func(ctx, useZizmor, usePoutine, useActionlint, useRunnerGuard, useGrype, useYamllint bool) error` | Pre-pulls security-scanner Docker images | | `UpdateContainerPins` | `func(ctx, workflowDir string, verbose bool) error` | Updates container image SHA pins in workflow files | | `CreatePRWithChanges` | `func(branchPrefix, commitMessage, prTitle, prBody string, verbose bool) (string, error)` | Creates a GitHub PR from uncommitted changes | | `AutoMergePullRequestsCreatedAfter` | `func(repoSlug string, createdAfter time.Time, verbose bool) error` | Auto-merges eligible PRs created after a given time | diff --git a/pkg/cli/compile_config.go b/pkg/cli/compile_config.go index 91147e21c31..6eec4d6b3e1 100644 --- a/pkg/cli/compile_config.go +++ b/pkg/cli/compile_config.go @@ -27,6 +27,7 @@ type CompileConfig struct { RunnerGuard bool // Run runner-guard taint analysis scanner on generated .lock.yml files Syft bool // Run syft SBOM scanner on container images referenced in compiled .lock.yml files Grype bool // Run grype vulnerability scanner on container images referenced in compiled .lock.yml files + Yamllint bool // Run yamllint YAML linter on generated .lock.yml files JSONOutput bool // Output validation results as JSON ShowAllErrors bool // Display all prioritized errors instead of the default top five ActionMode string // How action scripts are referenced: dev, release, or action. Auto-detected if empty. diff --git a/pkg/cli/compile_external_tools.go b/pkg/cli/compile_external_tools.go index 422dae39f51..88cac82a708 100644 --- a/pkg/cli/compile_external_tools.go +++ b/pkg/cli/compile_external_tools.go @@ -1,7 +1,8 @@ // This file provides external tool runners for workflow compilation. // // This file contains functions that invoke external analysis tools -// (actionlint, zizmor, poutine, runner-guard, syft, grype) on compiled workflow files. +// (actionlint, zizmor, poutine, runner-guard, syft, grype, yamllint) +// on compiled workflow files. // // # Organization Rationale // @@ -18,6 +19,7 @@ // - RunZizmorOnFiles() - Run zizmor on multiple lock files // - RunPoutineOnDirectory() - Run poutine security scanner on a directory // - RunRunnerGuardOnDirectory() - Run runner-guard taint analysis on a directory +// - RunYamllintOnFiles() - Run yamllint YAML linter on multiple lock files package cli @@ -65,6 +67,12 @@ func RunGrypeOnLockFiles(lockFiles []string, verbose bool, strict bool) error { return runBatchLockFileTool("grype", lockFiles, verbose, strict, runGrypeOnLockFiles) } +// RunYamllintOnFiles runs yamllint on multiple lock files in a single batch. +// This is more efficient than running yamllint once per file. +func RunYamllintOnFiles(lockFiles []string, verbose bool, strict bool) error { + return runBatchLockFileTool("yamllint", lockFiles, verbose, strict, runYamllintOnFiles) +} + // RunSyftOnLockFiles runs the syft SBOM scanner on container images extracted // from the gh-aw-manifest headers in the provided lock files. func RunSyftOnLockFiles(lockFiles []string, verbose bool, strict bool) error { diff --git a/pkg/cli/compile_pipeline.go b/pkg/cli/compile_pipeline.go index 14ac3161f77..7a6b16c106c 100644 --- a/pkg/cli/compile_pipeline.go +++ b/pkg/cli/compile_pipeline.go @@ -37,6 +37,7 @@ import ( ) var compileOrchestrationLog = logger.New("cli:compile_pipeline") +var runBatchYamllintOnFiles = RunYamllintOnFiles const fallbackCompilationErrorMessage = "compilation failed (no detailed error message available)" @@ -65,6 +66,7 @@ func compileSpecificFiles( var lockFilesForDirTools []string // lock files for directory-based tools (poutine, runner-guard) var lockFilesForSyft []string // lock files for syft container image SBOM scanning var lockFilesForGrype []string // lock files for grype container image vulnerability scanning + var lockFilesForYamllint []string // lock files for yamllint YAML linter // Compile each specified file for _, markdownFile := range config.MarkdownFiles { @@ -150,12 +152,12 @@ func compileSpecificFiles( if config.Poutine || config.RunnerGuard { lockFilesForDirTools = append(lockFilesForDirTools, fileResult.lockFile) } - if config.Grype { - lockFilesForGrype = append(lockFilesForGrype, fileResult.lockFile) - } if config.Syft { lockFilesForSyft = append(lockFilesForSyft, fileResult.lockFile) } + if config.Yamllint { + lockFilesForYamllint = append(lockFilesForYamllint, fileResult.lockFile) + } } } } @@ -239,6 +241,18 @@ func compileSpecificFiles( } } + // Run yamllint on all collected lock files. + if config.Yamllint && !config.NoEmit && len(lockFilesForYamllint) > 0 { + if err := ctx.Err(); err != nil { + return workflowDataList, err + } + if err := runBatchYamllintOnFiles(lockFilesForYamllint, config.Verbose && !config.JSONOutput, config.Strict); err != nil { + if config.Strict { + return workflowDataList, err + } + } + } + // Get warning count from compiler stats.Warnings = compiler.GetWarningCount() @@ -336,6 +350,7 @@ func compileAllFilesInDirectory( var lockFilesForDirTools []string // lock files for directory-based tools (poutine, runner-guard) var lockFilesForSyft []string // lock files for syft container image SBOM scanning var lockFilesForGrype []string // lock files for grype container image vulnerability scanning + var lockFilesForYamllint []string // lock files for yamllint YAML linter for _, file := range mdFiles { // Respect context cancellation between files (e.g. Ctrl+C) @@ -390,11 +405,14 @@ func compileAllFilesInDirectory( if config.Poutine || config.RunnerGuard { lockFilesForDirTools = append(lockFilesForDirTools, fileResult.lockFile) } + if config.Syft { + lockFilesForSyft = append(lockFilesForSyft, fileResult.lockFile) + } if config.Grype { lockFilesForGrype = append(lockFilesForGrype, fileResult.lockFile) } - if config.Syft { - lockFilesForSyft = append(lockFilesForSyft, fileResult.lockFile) + if config.Yamllint { + lockFilesForYamllint = append(lockFilesForYamllint, fileResult.lockFile) } } } @@ -475,6 +493,18 @@ func compileAllFilesInDirectory( } } + // Run batch yamllint + if config.Yamllint && !config.NoEmit && len(lockFilesForYamllint) > 0 { + if err := ctx.Err(); err != nil { + return workflowDataList, err + } + if err := runBatchYamllintOnFiles(lockFilesForYamllint, config.Verbose && !config.JSONOutput, config.Strict); err != nil { + if config.Strict { + return workflowDataList, err + } + } + } + // Emit recommendation when many slash commands are present without centralized strategy. displayCentralizedSlashCommandRecommendation(compiler, workflowDataList, config.JSONOutput) diff --git a/pkg/cli/compile_pipeline_yamllint_test.go b/pkg/cli/compile_pipeline_yamllint_test.go new file mode 100644 index 00000000000..a07b6b3a71a --- /dev/null +++ b/pkg/cli/compile_pipeline_yamllint_test.go @@ -0,0 +1,100 @@ +//go:build !integration + +package cli + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompileWorkflows_RunsYamllintForSpecificFiles(t *testing.T) { + compileWorkflowsRunsYamllintHelper(t, []string{"test"}, true, nil, false) +} + +func TestCompileWorkflows_RunsYamllintForDirectoryCompile(t *testing.T) { + compileWorkflowsRunsYamllintHelper(t, nil, false, nil, false) +} + +func TestCompileWorkflows_YamllintErrorHandling(t *testing.T) { + t.Run("strict mode returns yamllint error", func(t *testing.T) { + compileWorkflowsRunsYamllintHelper(t, []string{"test"}, true, assert.AnError, true) + }) + + t.Run("non-strict mode swallows yamllint error", func(t *testing.T) { + compileWorkflowsRunsYamllintHelper(t, nil, false, assert.AnError, false) + }) +} + +func compileWorkflowsRunsYamllintHelper(t *testing.T, markdownFiles []string, strict bool, runnerErr error, expectCompileErr bool) { + t.Helper() + + tmpDir := t.TempDir() + require.NoError(t, initTestGitRepo(tmpDir)) + + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + require.NoError(t, os.MkdirAll(workflowsDir, 0o755)) + + workflowContent := `--- +name: Test Workflow +on: + workflow_dispatch: +permissions: + contents: read + issues: read + pull-requests: read +strict: false +--- + +# Test Workflow + +This is a test workflow for yamllint batch execution. +` + require.NoError(t, os.WriteFile(filepath.Join(workflowsDir, "test.md"), []byte(workflowContent), 0o644)) + + oldWD, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmpDir)) + t.Cleanup(func() { + _ = os.Chdir(oldWD) + }) + + originalRunner := runBatchYamllintOnFiles + t.Cleanup(func() { + runBatchYamllintOnFiles = originalRunner + }) + + var gotLockFiles []string + var gotVerbose bool + var gotStrict bool + var calls int + runBatchYamllintOnFiles = func(lockFiles []string, verbose bool, strictArg bool) error { + calls++ + gotLockFiles = append([]string(nil), lockFiles...) + gotVerbose = verbose + gotStrict = strictArg + return runnerErr + } + + config := CompileConfig{ + MarkdownFiles: markdownFiles, + NoEmit: false, + Yamllint: true, + Strict: strict, + } + + _, err = CompileWorkflows(context.Background(), config) + if expectCompileErr { + require.ErrorIs(t, err, runnerErr) + } else { + require.NoError(t, err) + } + require.Equal(t, 1, calls) + assert.Equal(t, []string{filepath.Join(workflowsDir, "test.lock.yml")}, gotLockFiles) + assert.False(t, gotVerbose) + assert.Equal(t, strict, gotStrict) +} diff --git a/pkg/cli/docker_images.go b/pkg/cli/docker_images.go index 18b6789adfd..8da2b9adb8b 100644 --- a/pkg/cli/docker_images.go +++ b/pkg/cli/docker_images.go @@ -35,6 +35,7 @@ const ( RunnerGuardImage = "ghcr.io/vigilant-llc/runner-guard:latest" SyftImage = "anchore/syft:v1.48.0" GrypeImage = "anchore/grype:latest" + YamllintImage = "pipelinecomponents/yamllint:latest" ) // dockerPullState tracks the state of docker pull operations @@ -226,9 +227,9 @@ func StartDockerImageDownload(ctx context.Context, image string) bool { // Returns: // - nil if all required images are available // - error if Docker is unavailable or images are downloading/need to be downloaded -func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, useActionlint, useRunnerGuard, useSyft, useGrype bool) error { +func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, useActionlint, useRunnerGuard, useSyft, useGrype, useYamllint bool) error { // If no tools requested, nothing to do - if !useZizmor && !usePoutine && !useActionlint && !useRunnerGuard && !useSyft && !useGrype { + if !useZizmor && !usePoutine && !useActionlint && !useRunnerGuard && !useSyft && !useGrype && !useYamllint { return nil } @@ -266,6 +267,11 @@ func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, use requestedTools = append(requestedTools, tool) paramsList = append(paramsList, tool+": false") } + if useYamllint { + tool := "yamllint" + requestedTools = append(requestedTools, tool) + paramsList = append(paramsList, tool+": false") + } verb := "requires" if len(requestedTools) > 1 { verb = "require" @@ -290,6 +296,7 @@ func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, use {useRunnerGuard, RunnerGuardImage, "runner-guard"}, {useSyft, SyftImage, "syft"}, {useGrype, GrypeImage, "grype"}, + {useYamllint, YamllintImage, "yamllint"}, } for _, img := range imagesToCheck { diff --git a/pkg/cli/docker_images_test.go b/pkg/cli/docker_images_test.go index 26e1bb57e2d..d7d9ef7a663 100644 --- a/pkg/cli/docker_images_test.go +++ b/pkg/cli/docker_images_test.go @@ -15,7 +15,7 @@ func TestCheckAndPrepareDockerImages_NoToolsRequested(t *testing.T) { ResetDockerPullState() // When no tools are requested, should return nil - err := CheckAndPrepareDockerImages(context.Background(), false, false, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), false, false, false, false, false, false, false) if err != nil { t.Errorf("Expected no error when no tools requested, got: %v", err) } @@ -31,7 +31,7 @@ func TestCheckAndPrepareDockerImages_ImageAlreadyDownloading(t *testing.T) { SetDockerImageDownloading(ZizmorImage, true) // Should return an error indicating to retry - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false, false) if err == nil { t.Error("Expected error when image is downloading, got nil") } @@ -142,7 +142,7 @@ func TestCheckAndPrepareDockerImages_MultipleImages(t *testing.T) { SetDockerImageDownloading(PoutineImage, true) // Request all tools - err := CheckAndPrepareDockerImages(context.Background(), true, true, true, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, true, true, false, false, false, false) if err == nil { t.Error("Expected error when images are downloading, got nil") } @@ -168,7 +168,7 @@ func TestCheckAndPrepareDockerImages_RetryMessageFormat(t *testing.T) { // Simulate zizmor downloading SetDockerImageDownloading(ZizmorImage, true) - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false, false) if err == nil { t.Fatal("Expected error when image is downloading") } @@ -203,7 +203,7 @@ func TestCheckAndPrepareDockerImages_StartedDownloadingMessage(t *testing.T) { // when the image is marked as downloading SetDockerImageDownloading(ZizmorImage, true) - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false, false) if err == nil { t.Fatal("Expected error when image is downloading") } @@ -227,7 +227,7 @@ func TestCheckAndPrepareDockerImages_ImageAlreadyAvailable(t *testing.T) { SetMockImageAvailable(ZizmorImage, true) // Should not return an error since the image is available - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false, false) if err != nil { t.Errorf("Expected no error when image is available, got: %v", err) } @@ -534,7 +534,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable(t *testing.T) { SetMockDockerAvailable(false) // Should return a clear error about Docker not being available - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -572,7 +572,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_MultipleTools(t *testing. SetMockDockerAvailable(false) // Request multiple tools - err := CheckAndPrepareDockerImages(context.Background(), true, false, true, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, true, false, false, false, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -611,7 +611,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_NoTools(t *testing.T) { SetMockDockerAvailable(false) // When no tools requested, should return nil even if Docker is unavailable - err := CheckAndPrepareDockerImages(context.Background(), false, false, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), false, false, false, false, false, false, false) if err != nil { t.Errorf("Expected no error when no tools requested (even with Docker unavailable), got: %v", err) } @@ -643,7 +643,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_ReturnsTypedError(t *test ResetDockerPullState() SetMockDockerAvailable(false) - err := CheckAndPrepareDockerImages(context.Background(), false, false, true, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), false, false, true, false, false, false, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -672,7 +672,7 @@ func TestCheckAndPrepareDockerImages_RunnerGuardImageDownloading(t *testing.T) { SetDockerImageDownloading(RunnerGuardImage, true) // Request all tools, including runner-guard - err := CheckAndPrepareDockerImages(context.Background(), true, true, true, true, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, true, true, true, false, false, false) if err == nil { t.Error("Expected error when images are downloading, got nil") } diff --git a/pkg/cli/mcp_argument_validation_test.go b/pkg/cli/mcp_argument_validation_test.go index a003450e166..a0b1182b500 100644 --- a/pkg/cli/mcp_argument_validation_test.go +++ b/pkg/cli/mcp_argument_validation_test.go @@ -95,7 +95,7 @@ func TestExtractUnknownParamsFromSchemaError(t *testing.T) { // TestFindSimilarParam verifies the fuzzy matching of parameter names. func TestFindSimilarParam(t *testing.T) { - compileParams := []string{"actionlint", "fix", "grype", "max_tokens", "poutine", "runner-guard", "strict", "syft", "workflows", "zizmor"} + compileParams := []string{"actionlint", "fix", "grype", "max_tokens", "poutine", "runner-guard", "strict", "syft", "workflows", "yamllint", "zizmor"} tests := []struct { name string @@ -206,7 +206,7 @@ func TestBuildHelpfulParamError(t *testing.T) { // that the middleware replaces raw schema validation errors with helpful messages. func TestArgumentValidationMiddleware_TransformsAdditionalPropertiesError(t *testing.T) { toolParams := map[string]toolParamEntry{ - "compile": {"actionlint", "fix", "grype", "max_tokens", "poutine", "runner-guard", "strict", "syft", "workflows", "zizmor"}, + "compile": {"actionlint", "fix", "grype", "max_tokens", "poutine", "runner-guard", "strict", "syft", "workflows", "yamllint", "zizmor"}, } middleware := argumentValidationMiddleware(toolParams) diff --git a/pkg/cli/mcp_tools_readonly.go b/pkg/cli/mcp_tools_readonly.go index a37f72e761a..ab3f4079a6f 100644 --- a/pkg/cli/mcp_tools_readonly.go +++ b/pkg/cli/mcp_tools_readonly.go @@ -77,6 +77,7 @@ type compileArgs struct { RunnerGuard bool `json:"runner-guard,omitempty" jsonschema:"Run runner-guard taint analysis scanner on generated .lock.yml files"` Syft bool `json:"syft,omitempty" jsonschema:"Run syft SBOM scanner on container images referenced in compiled .lock.yml files"` Grype bool `json:"grype,omitempty" jsonschema:"Run grype vulnerability scanner on container images referenced in compiled .lock.yml files"` + Yamllint bool `json:"yamllint,omitempty" jsonschema:"Run yamllint YAML linter on generated .lock.yml files"` Fix bool `json:"fix,omitempty" jsonschema:"Apply automatic codemod fixes to workflows before compiling"` MaxTokens int `json:"max_tokens,omitempty" jsonschema:"Deprecated: accepted for backward compatibility but ignored."` } @@ -140,9 +141,9 @@ Returns JSON array with validation results for each workflow: var dockerUnavailableWarning string // Check if any static analysis tools are requested that require Docker images - if args.Zizmor || args.Poutine || args.Actionlint || args.RunnerGuard || args.Syft || args.Grype { + if args.Zizmor || args.Poutine || args.Actionlint || args.RunnerGuard || args.Syft || args.Grype || args.Yamllint { // Check if Docker images are available; if not, start downloading and return retry message - if err := CheckAndPrepareDockerImages(ctx, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard, args.Syft, args.Grype); err != nil { + if err := CheckAndPrepareDockerImages(ctx, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard, args.Syft, args.Grype, args.Yamllint); err != nil { var dockerUnavailableErr *DockerUnavailableError if errors.As(err, &dockerUnavailableErr) { // Docker daemon is not running. Instead of failing every workflow, @@ -155,6 +156,7 @@ Returns JSON array with validation results for each workflow: args.RunnerGuard = false args.Syft = false args.Grype = false + args.Yamllint = false } else { // Images are still downloading — ask the caller to retry. // Build per-workflow validation errors instead of throwing an MCP protocol error, @@ -211,6 +213,9 @@ Returns JSON array with validation results for each workflow: if args.Grype { cmdArgs = append(cmdArgs, "--grype") } + if args.Yamllint { + cmdArgs = append(cmdArgs, "--yamllint") + } cmdArgs = append(cmdArgs, args.Workflows...) @@ -220,8 +225,8 @@ Returns JSON array with validation results for each workflow: cmdArgs = append(cmdArgs, "--prior-manifest-file", manifestCacheFile) } - mcpLog.Printf("Executing compile tool: workflows=%v, strict=%v, fix=%v, zizmor=%v, poutine=%v, actionlint=%v, runner-guard=%v, syft=%v, grype=%v", - args.Workflows, args.Strict, args.Fix, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard, args.Syft, args.Grype) + mcpLog.Printf("Executing compile tool: workflows=%v, strict=%v, fix=%v, zizmor=%v, poutine=%v, actionlint=%v, runner-guard=%v, syft=%v, grype=%v, yamllint=%v", + args.Workflows, args.Strict, args.Fix, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard, args.Syft, args.Grype, args.Yamllint) // Execute the CLI command // Use separate stdout/stderr capture instead of CombinedOutput because: diff --git a/pkg/cli/yamllint.go b/pkg/cli/yamllint.go new file mode 100644 index 00000000000..e9ea09df3d8 --- /dev/null +++ b/pkg/cli/yamllint.go @@ -0,0 +1,256 @@ +package cli + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/gitutil" + "github.com/github/gh-aw/pkg/logger" +) + +var yamllintLog = logger.New("cli:yamllint") + +// yamllintDefaultConfig is the inline yamllint configuration used for lock files. +// It disables rules that produce excessive noise on generated YAML output. +const yamllintDefaultConfig = `{extends: default, rules: {line-length: disable, document-start: disable, truthy: {check-keys: false}, comments: {require-starting-space: true, min-spaces-from-content: 1}}}` + +// yamllintIssue represents a single issue from yamllint parsable output. +type yamllintIssue struct { + File string + Line int + Column int + Level string + Message string + Rule string +} + +// yamllintParsableRegex matches a single line of yamllint --format parsable output: +// +// {file}:{line}:{col}: [{level}] {message} ({rule}) +var yamllintParsableRegex = regexp.MustCompile(`^(.+):(\d+):(\d+): \[(error|warning)\] (.+) \(([^)]+)\)$`) + +// runYamllintOnFiles runs yamllint on one or more .lock.yml files using Docker. +func runYamllintOnFiles(lockFiles []string, verbose bool, strict bool) error { + if len(lockFiles) == 0 { + return nil + } + + yamllintLog.Printf("Running yamllint on %d file(s): %v (verbose=%t, strict=%t)", len(lockFiles), lockFiles, verbose, strict) + + gitRoot, err := gitutil.FindGitRoot() + if err != nil { + return fmt.Errorf("failed to find git root: %w", err) + } + + if !filepath.IsAbs(gitRoot) { + return fmt.Errorf("git root must be an absolute path, got: %s", gitRoot) + } + + relPaths, err := buildYamllintContainerPaths(gitRoot, lockFiles) + if err != nil { + return err + } + + // #nosec G204 -- gitRoot is validated as an absolute path (from git rev-parse, a trusted source). + // relPaths are repository-relative paths that have been cleaned, validated to stay within + // gitRoot, and prefixed with "./" to prevent option injection. exec.Command passes args + // directly to the OS (no shell), preventing shell injection. + // yamllintDefaultConfig is a compile-time constant with no user-controlled content. + dockerArgs := buildYamllintDockerArgs(gitRoot, relPaths, strict) + + if len(lockFiles) == 1 { + fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Running yamllint on "+relPaths[0])) + } else { + fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage(fmt.Sprintf("Running yamllint on %d files", len(lockFiles)))) + } + + if verbose { + dockerCmd := buildYamllintVerboseCommand(gitRoot, relPaths, strict) + fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Run yamllint directly: "+dockerCmd)) + } + + cmd := exec.Command("docker", dockerArgs...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + + totalIssues, parseErr := parseAndDisplayYamllintOutput(stdout.String()) + if parseErr != nil { + yamllintLog.Printf("Failed to parse yamllint output: %v", parseErr) + if stdout.Len() > 0 { + fmt.Fprint(os.Stderr, stdout.String()) + } + if stderr.Len() > 0 { + fmt.Fprint(os.Stderr, stderr.String()) + } + } + + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return classifyYamllintExit(exitErr.ExitCode(), strict, totalIssues, yamllintFileDescription(lockFiles)) + } + return fmt.Errorf("yamllint failed: %w", err) + } + + return nil +} + +func buildYamllintDockerArgs(gitRoot string, relPaths []string, strict bool) []string { + dockerArgs := []string{ + "run", + "--rm", + "-v", gitRoot + ":/workdir", + "-w", "/workdir", + YamllintImage, + "-d", yamllintDefaultConfig, + "--format", "parsable", + } + if strict { + dockerArgs = append(dockerArgs, "--strict") + } + return append(dockerArgs, relPaths...) +} + +func buildYamllintVerboseCommand(gitRoot string, relPaths []string, strict bool) string { + args := append([]string{"docker"}, buildYamllintDockerArgs(gitRoot, relPaths, strict)...) + quoted := make([]string, 0, len(args)) + for _, arg := range args { + quoted = append(quoted, quoteYamllintShellArg(arg)) + } + return strings.Join(quoted, " ") +} + +func yamllintFileDescription(lockFiles []string) string { + if len(lockFiles) == 1 { + return filepath.Base(lockFiles[0]) + } + return "workflows" +} + +func classifyYamllintExit(exitCode int, strict bool, totalIssues int, fileDescription string) error { + yamllintLog.Printf("yamllint exited with code %d (issues=%d)", exitCode, totalIssues) + if exitCode == 1 || (exitCode == 2 && strict && totalIssues > 0) { + if strict { + return fmt.Errorf("strict mode: yamllint found %d issue(s) in %s - workflows must have no yamllint issues in strict mode", totalIssues, fileDescription) + } + return nil + } + return fmt.Errorf("yamllint failed with exit code %d on %s", exitCode, fileDescription) +} + +func quoteYamllintShellArg(arg string) string { + if arg != "" && !strings.ContainsAny(arg, " \t\n\"'`$\\;|&<>*?!#~") { + return arg + } + return strconv.Quote(arg) +} + +func buildYamllintContainerPaths(gitRoot string, lockFiles []string) ([]string, error) { + relPaths := make([]string, 0, len(lockFiles)) + for _, lockFile := range lockFiles { + absLockFile, err := filepath.Abs(lockFile) + if err != nil { + return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", lockFile, err) + } + relPath, err := filepath.Rel(gitRoot, absLockFile) + if err != nil { + return nil, fmt.Errorf("failed to get relative path for %s: %w", lockFile, err) + } + cleanRelPath := filepath.Clean(relPath) + if cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, ".."+string(filepath.Separator)) { + return nil, fmt.Errorf("yamllint file path %s is outside repository root", lockFile) + } + relPaths = append(relPaths, "./"+filepath.ToSlash(cleanRelPath)) + } + return relPaths, nil +} + +// parseAndDisplayYamllintOutput parses yamllint --format parsable output and displays findings. +// Returns the total number of issues found. +func parseAndDisplayYamllintOutput(stdout string) (int, error) { + if strings.TrimSpace(stdout) == "" { + return 0, nil + } + + totalIssues := 0 + scanner := bufio.NewScanner(strings.NewReader(stdout)) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + issue, err := parseYamllintLine(line) + if err != nil { + yamllintLog.Printf("Failed to parse yamllint line %q: %v", line, err) + fmt.Fprintf(os.Stderr, "%s\n", console.FormatWarningMessage("Failed to parse yamllint output line: "+line)) + fmt.Fprintln(os.Stderr, line) + continue + } + + totalIssues++ + + errorType := "warning" + if issue.Level == "error" { + errorType = "error" + } + + compilerErr := console.CompilerError{ + Position: console.ErrorPosition{ + File: issue.File, + Line: issue.Line, + Column: issue.Column, + }, + Type: errorType, + Message: fmt.Sprintf("[%s] %s (%s)", issue.Level, issue.Message, issue.Rule), + } + + fmt.Fprint(os.Stderr, console.FormatError(compilerErr)) + } + + if err := scanner.Err(); err != nil { + return totalIssues, fmt.Errorf("failed to scan yamllint output: %w", err) + } + + return totalIssues, nil +} + +// parseYamllintLine parses a single line of yamllint --format parsable output. +// Expected format: {file}:{line}:{col}: [{level}] {message} ({rule}) +func parseYamllintLine(line string) (yamllintIssue, error) { + matches := yamllintParsableRegex.FindStringSubmatch(line) + if matches == nil { + return yamllintIssue{}, fmt.Errorf("line does not match yamllint parsable format: %q", line) + } + + lineNum, err := strconv.Atoi(matches[2]) + if err != nil { + return yamllintIssue{}, fmt.Errorf("failed to parse line number %q: %w", matches[2], err) + } + + colNum, err := strconv.Atoi(matches[3]) + if err != nil { + return yamllintIssue{}, fmt.Errorf("failed to parse column number %q: %w", matches[3], err) + } + + return yamllintIssue{ + File: matches[1], + Line: lineNum, + Column: colNum, + Level: matches[4], + Message: matches[5], + Rule: matches[6], + }, nil +} diff --git a/pkg/cli/yamllint_test.go b/pkg/cli/yamllint_test.go new file mode 100644 index 00000000000..8cc20978923 --- /dev/null +++ b/pkg/cli/yamllint_test.go @@ -0,0 +1,162 @@ +//go:build !integration + +package cli + +import ( + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseYamllintLine(t *testing.T) { + t.Run("parses error line", func(t *testing.T) { + issue, err := parseYamllintLine("./.github/workflows/test.lock.yml:7:9: [error] wrong indentation: expected 8 but found 10 (indentation)") + + require.NoError(t, err) + assert.Equal(t, yamllintIssue{ + File: "./.github/workflows/test.lock.yml", + Line: 7, + Column: 9, + Level: "error", + Message: "wrong indentation: expected 8 but found 10", + Rule: "indentation", + }, issue) + }) + + t.Run("parses warning line", func(t *testing.T) { + issue, err := parseYamllintLine("./test.lock.yml:1:1: [warning] missing document start \"---\" (document-start)") + + require.NoError(t, err) + assert.Equal(t, yamllintIssue{ + File: "./test.lock.yml", + Line: 1, + Column: 1, + Level: "warning", + Message: "missing document start \"---\"", + Rule: "document-start", + }, issue) + }) + + t.Run("rejects malformed line", func(t *testing.T) { + _, err := parseYamllintLine("not parsable output") + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match yamllint parsable format") + }) +} + +func TestParseAndDisplayYamllintOutput(t *testing.T) { + stdout, stderr := captureOutput(t, func() error { + issues, err := parseAndDisplayYamllintOutput(strings.Join([]string{ + "./test.lock.yml:1:1: [warning] missing document start \"---\" (document-start)", + "malformed output", + "./test.lock.yml:2:3: [error] syntax error: expected , but found '-' (syntax)", + }, "\n")) + require.NoError(t, err) + assert.Equal(t, 2, issues) + return nil + }) + + assert.Empty(t, stdout) + assert.Contains(t, stderr, "test.lock.yml:1:1") + assert.Contains(t, stderr, "[warning] missing document start \"---\" (document-start)") + assert.Contains(t, stderr, "Failed to parse yamllint output line: malformed output") + assert.Contains(t, stderr, "test.lock.yml:2:3") + assert.Contains(t, stderr, "[error] syntax error: expected , but found '-' (syntax)") +} + +func TestParseAndDisplayYamllintOutput_AllMalformed(t *testing.T) { + stdout, stderr := captureOutput(t, func() error { + issues, err := parseAndDisplayYamllintOutput("bad line one\nbad line two") + require.NoError(t, err) + assert.Equal(t, 0, issues) + return nil + }) + + assert.Empty(t, stdout) + assert.Contains(t, stderr, "Failed to parse yamllint output line: bad line one") + assert.Contains(t, stderr, "Failed to parse yamllint output line: bad line two") +} + +func TestBuildYamllintContainerPaths(t *testing.T) { + t.Run("normalizes in-repo path", func(t *testing.T) { + gitRoot := t.TempDir() + lockFile := filepath.Join(gitRoot, ".github", "workflows", "static-analysis-report.lock.yml") + + paths, err := buildYamllintContainerPaths(gitRoot, []string{lockFile}) + + require.NoError(t, err) + assert.Equal(t, []string{"./.github/workflows/static-analysis-report.lock.yml"}, paths) + }) + + t.Run("rejects path outside repository", func(t *testing.T) { + gitRoot := t.TempDir() + lockFile := filepath.Join(filepath.Dir(gitRoot), "outside.lock.yml") + + _, err := buildYamllintContainerPaths(gitRoot, []string{lockFile}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "outside repository root") + }) + + t.Run("prefixes option-looking root file", func(t *testing.T) { + gitRoot := t.TempDir() + lockFile := filepath.Join(gitRoot, "-workflow.lock.yml") + + paths, err := buildYamllintContainerPaths(gitRoot, []string{lockFile}) + + require.NoError(t, err) + assert.Equal(t, []string{"./-workflow.lock.yml"}, paths) + }) +} + +func TestBuildYamllintDockerArgs(t *testing.T) { + args := buildYamllintDockerArgs("/repo", []string{"./test.lock.yml"}, true) + + assert.Equal(t, []string{ + "run", + "--rm", + "-v", "/repo:/workdir", + "-w", "/workdir", + YamllintImage, + "-d", yamllintDefaultConfig, + "--format", "parsable", + "--strict", + "./test.lock.yml", + }, args) + assert.Equal(t, + "docker run --rm -v /repo:/workdir -w /workdir "+YamllintImage+" -d "+strconv.Quote(yamllintDefaultConfig)+" --format parsable --strict ./test.lock.yml", + buildYamllintVerboseCommand("/repo", []string{"./test.lock.yml"}, true), + ) + assert.Equal(t, + "docker run --rm -v "+strconv.Quote("/repo root:/workdir")+" -w /workdir "+YamllintImage+" -d "+strconv.Quote(yamllintDefaultConfig)+" --format parsable ./workflow.lock.yml", + buildYamllintVerboseCommand("/repo root", []string{"./workflow.lock.yml"}, false), + ) +} + +func TestClassifyYamllintExit(t *testing.T) { + t.Run("non-strict exit code 1 is tolerated", func(t *testing.T) { + assert.NoError(t, classifyYamllintExit(1, false, 2, "workflows")) + }) + + t.Run("strict exit code 1 fails", func(t *testing.T) { + err := classifyYamllintExit(1, true, 2, "test.lock.yml") + require.Error(t, err) + assert.Contains(t, err.Error(), "strict mode: yamllint found 2 issue(s) in test.lock.yml") + }) + + t.Run("strict warning-only exit code 2 fails", func(t *testing.T) { + err := classifyYamllintExit(2, true, 1, "workflows") + require.Error(t, err) + assert.Contains(t, err.Error(), "strict mode: yamllint found 1 issue(s) in workflows") + }) + + t.Run("unexpected exit code is returned", func(t *testing.T) { + err := classifyYamllintExit(3, false, 0, "workflows") + require.Error(t, err) + assert.EqualError(t, err, "yamllint failed with exit code 3 on workflows") + }) +}