From 060f589756170dbd7983f4f4a08bba46a1d240b6 Mon Sep 17 00:00:00 2001 From: mackeh Date: Wed, 11 Feb 2026 19:55:55 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20v2.2-v2.3=20roadmap=20?= =?UTF-8?q?=E2=80=94=20security=20scanning,=20linting,=20policies,=20and?= =?UTF-8?q?=20new=20CLI=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add security module with secrets detection (AWS keys, GitHub PATs, Docker passwords, private keys, Slack webhooks), permissions auditing, expression injection detection, and supply chain risk assessment for third-party actions. Add config linter with deprecation checks (outdated action versions, deprecated GitLab keywords), schema validation (GitHub Actions and GitLab CI structure), and typo detection using Damerau-Levenshtein fuzzy matching. Add compliance policy engine with configurable rules (SHA pinning, banned runners, required caching, max duration, concurrency control) loaded from .pipelinex/policy.toml. New CLI commands: completions, init, compare, watch, lint, security, policy. New output format: markdown (--format markdown). All 123 tests passing, clippy clean, formatted. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 81 +++ Cargo.lock | 240 ++++++++- Cargo.toml | 4 + crates/pipelinex-cli/Cargo.toml | 4 + crates/pipelinex-cli/src/display.rs | 381 ++++++++++++++ crates/pipelinex-cli/src/main.rs | 463 +++++++++++++++++- crates/pipelinex-core/Cargo.toml | 2 + crates/pipelinex-core/src/lib.rs | 6 + .../pipelinex-core/src/linter/deprecation.rs | 171 +++++++ crates/pipelinex-core/src/linter/mod.rs | 90 ++++ crates/pipelinex-core/src/linter/schema.rs | 175 +++++++ crates/pipelinex-core/src/linter/typo.rs | 192 ++++++++ crates/pipelinex-core/src/policy/mod.rs | 318 ++++++++++++ .../pipelinex-core/src/security/injection.rs | 118 +++++ crates/pipelinex-core/src/security/mod.rs | 17 + .../src/security/permissions.rs | 128 +++++ crates/pipelinex-core/src/security/secrets.rs | 191 ++++++++ .../src/security/supply_chain.rs | 219 +++++++++ 18 files changed, 2792 insertions(+), 8 deletions(-) create mode 100644 CLAUDE.md create mode 100644 crates/pipelinex-core/src/linter/deprecation.rs create mode 100644 crates/pipelinex-core/src/linter/mod.rs create mode 100644 crates/pipelinex-core/src/linter/schema.rs create mode 100644 crates/pipelinex-core/src/linter/typo.rs create mode 100644 crates/pipelinex-core/src/policy/mod.rs create mode 100644 crates/pipelinex-core/src/security/injection.rs create mode 100644 crates/pipelinex-core/src/security/mod.rs create mode 100644 crates/pipelinex-core/src/security/permissions.rs create mode 100644 crates/pipelinex-core/src/security/secrets.rs create mode 100644 crates/pipelinex-core/src/security/supply_chain.rs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ff93348 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,81 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Development Commands + +```bash +# Build +cargo build --release # Release binary → target/release/pipelinex +cargo build # Debug build + +# Test +cargo test --all # All tests (unit + integration) +cargo test --all -- --nocapture # Tests with stdout +cargo test test_name # Single test by name +cargo test --test integration_tests # Integration tests only (in pipelinex-core) + +# Lint & Format +cargo clippy --all-targets -- -D warnings # Clippy (CI enforces -D warnings) +cargo fmt --all # Format +cargo fmt --all -- --check # Check formatting without changing + +# Install locally +cargo install --path crates/pipelinex-cli --force +``` + +There is also a `Makefile` with shortcuts (`make test`, `make lint`, `make fmt`, `make all`). + +## Architecture + +### Workspace Layout + +Two crates in a Cargo workspace: +- **`pipelinex-core`** — Library crate with all analysis logic +- **`pipelinex-cli`** — Binary crate (`pipelinex`) that wires CLI args to core functions + +A **`dashboard/`** directory contains a separate Next.js (React) web app for visualization — it is not part of the Rust workspace. + +### Core Data Flow + +All CI configs (8 providers) are normalized into one shared type: + +``` +CI Config File → Parser → PipelineDag → Analyzer → AnalysisReport → Optimizer/Output +``` + +1. **Parsers** (`parser/*.rs`) — Each CI provider has a `parse_file(path) -> Result` function. Provider detection is done by filename/path matching in `pipelinex-cli/src/main.rs:parse_pipeline()`. + +2. **`PipelineDag`** (`parser/dag.rs`) — The universal pipeline representation. Uses `petgraph::DiGraph` with a `node_map: HashMap` for ID-based lookup. **Cannot derive Serialize** because `petgraph::Graph` and `NodeIndex` don't implement it. + +3. **Analyzers** (`analyzer/mod.rs`) — `analyze(&PipelineDag) -> AnalysisReport` runs all detectors in sequence: critical path, cache, parallelization, waste, runner sizing, plus external plugins. Each detector module returns `Vec`. + +4. **Optimizer** (`optimizer/mod.rs`) — Takes the original YAML file + `AnalysisReport` and applies fixes by mutating a `serde_yaml::Value` tree. Sub-modules handle cache injection, parallelization, sharding, and Docker optimizations. + +5. **CLI display** (`pipelinex-cli/src/display.rs`) — All terminal formatting is here. Output formats: text (colored), JSON, SARIF, HTML. + +### Key Types + +- `PipelineDag` — Core DAG with `graph`, `node_map`, `triggers`, `provider` +- `JobNode` — A CI job: steps, dependencies, caches, matrix, conditions, env +- `AnalysisReport` — Full analysis output with findings, health score, timing +- `Finding` — Single issue with severity, category, recommendation, estimated savings +- `Severity` — Critical > High > Medium > Low > Info (sorted by `priority()`) + +### Adding a New CI Parser + +Each parser lives in `parser/.rs` and exposes a struct with `parse_file(path) -> Result`. Add the new module to `parser/mod.rs`, re-export from `lib.rs`, add filename detection logic in `main.rs:parse_pipeline()`, and add fixtures under `tests/fixtures//`. + +### Adding a New Analyzer + +Create a module in `analyzer/` that takes `&PipelineDag` and returns `Vec`. Wire it into `analyzer::analyze()` in `analyzer/mod.rs`. Add a `FindingCategory` variant in `report.rs`. + +## Test Fixtures + +Test fixtures live at `tests/fixtures/` (workspace root, not inside any crate). Integration tests in `pipelinex-core/tests/integration_tests.rs` reference them via `CARGO_MANIFEST_DIR` walking up to workspace root. Fixture directories: `github-actions/`, `gitlab-ci/`, `jenkins/`, `circleci/`, `bitbucket/`, `azure-pipelines/`, `aws-codepipeline/`, `buildkite/`, `dockerfiles/`, `junit/`. + +## Known Gotchas + +- `serde_yaml::Value::get()` does not accept `bool` as an index — the YAML key `on: true` (GitHub Actions trigger) needs special handling +- `PipelineDag` uses `petgraph::DiGraph` which doesn't implement `Serialize`, so the DAG itself can't be serialized directly +- The optimizer mutates `serde_yaml::Value` in-place rather than building from the DAG, to preserve original YAML structure and comments as much as possible diff --git a/Cargo.lock b/Cargo.lock index 6a0669d..4239682 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" @@ -164,6 +170,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.5.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.5.55" @@ -255,6 +270,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -297,6 +323,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -651,6 +686,35 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -689,12 +753,43 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall 0.7.0", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -741,6 +836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -762,6 +858,34 @@ dependencies = [ "tempfile", ] +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.10.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -789,7 +913,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cfg-if", "foreign-types", "libc", @@ -845,7 +969,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -883,9 +1007,12 @@ name = "pipelinex-cli" version = "2.1.1" dependencies = [ "anyhow", + "chrono", "clap", + "clap_complete", "colored", "glob", + "notify", "pipelinex-core", "regex", "serde", @@ -893,6 +1020,7 @@ dependencies = [ "serde_yaml", "similar", "tokio", + "toml", ] [[package]] @@ -909,8 +1037,10 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "strsim", "thiserror", "tokio", + "toml", ] [[package]] @@ -968,7 +1098,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.10.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", ] [[package]] @@ -1060,7 +1199,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", @@ -1112,6 +1251,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -1133,7 +1281,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.10.0", "core-foundation", "core-foundation-sys", "libc", @@ -1193,6 +1341,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1317,7 +1474,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.10.0", "core-foundation", "system-configuration-sys", ] @@ -1436,6 +1593,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -1457,7 +1655,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.10.0", "bytes", "futures-util", "http", @@ -1554,6 +1752,16 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1647,6 +1855,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -1873,6 +2090,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 33abd7e..934f2f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,3 +32,7 @@ quick-xml = { version = "0.37", features = ["serialize"] } tokio = { version = "1", features = ["full"] } reqwest = { version = "0.12", features = ["json"] } chrono = { version = "0.4", features = ["serde"] } +clap_complete = "4" +notify = { version = "7", features = ["macos_fsevent"] } +toml = "0.8" +strsim = "0.11" diff --git a/crates/pipelinex-cli/Cargo.toml b/crates/pipelinex-cli/Cargo.toml index f88bbfd..68c7fad 100644 --- a/crates/pipelinex-cli/Cargo.toml +++ b/crates/pipelinex-cli/Cargo.toml @@ -21,3 +21,7 @@ similar = { workspace = true } glob = { workspace = true } tokio = { workspace = true } regex = { workspace = true } +clap_complete = { workspace = true } +notify = { workspace = true } +toml = { workspace = true } +chrono = { workspace = true } diff --git a/crates/pipelinex-cli/src/display.rs b/crates/pipelinex-cli/src/display.rs index de7af58..d24d2ca 100644 --- a/crates/pipelinex-cli/src/display.rs +++ b/crates/pipelinex-cli/src/display.rs @@ -2,7 +2,9 @@ use colored::*; use pipelinex_core::analyzer::report::{format_duration, AnalysisReport, Finding, Severity}; use pipelinex_core::cost::CostEstimate; use pipelinex_core::flaky_detector::{FlakyCategory, FlakyReport}; +use pipelinex_core::linter::{LintReport, LintSeverity}; use pipelinex_core::optimizer::docker_opt::{DockerAnalysis, DockerSeverity}; +use pipelinex_core::policy::{PolicyReport, PolicySeverity}; use pipelinex_core::runner_sizing::{RunnerSizeClass, RunnerSizingReport}; use pipelinex_core::simulator::SimulationResult; use pipelinex_core::test_selector::TestSelection; @@ -831,6 +833,385 @@ fn rank(class: RunnerSizeClass) -> u8 { } } +/// Generate markdown formatted analysis report. +pub fn format_markdown_report(report: &AnalysisReport) -> String { + let mut md = String::new(); + + md.push_str(&format!( + "# PipelineX Analysis — {}\n\n", + report.source_file + )); + + md.push_str("## Pipeline Structure\n\n"); + md.push_str(&format!( + "| Metric | Value |\n|--------|-------|\n| Jobs | {} |\n| Steps | {} |\n| Max Parallelism | {} |\n| Critical Path | {} ({}) |\n| Provider | {} |\n\n", + report.job_count, + report.step_count, + report.max_parallelism, + report.critical_path.join(" → "), + format_duration(report.critical_path_duration_secs), + report.provider, + )); + + if !report.findings.is_empty() { + md.push_str("## Findings\n\n"); + md.push_str("| Severity | Finding | Savings | Auto-fixable |\n"); + md.push_str("|----------|---------|---------|-------------|\n"); + + for finding in &report.findings { + let severity_icon = match finding.severity { + Severity::Critical => "🔴 CRITICAL", + Severity::High => "🟡 HIGH", + Severity::Medium => "🔵 MEDIUM", + Severity::Low => "⚪ LOW", + Severity::Info => "ℹ️ INFO", + }; + let savings = finding + .estimated_savings_secs + .map(|s| format!("{}/run", format_duration(s))) + .unwrap_or_else(|| "—".into()); + let fixable = if finding.auto_fixable { "✅" } else { "—" }; + + md.push_str(&format!( + "| {} | {} | {} | {} |\n", + severity_icon, finding.title, savings, fixable + )); + } + md.push('\n'); + + md.push_str("### Details\n\n"); + for finding in &report.findings { + md.push_str(&format!( + "#### {} — {}\n\n", + finding.severity.symbol(), + finding.title + )); + md.push_str(&format!("{}\n\n", finding.description)); + md.push_str(&format!( + "**Recommendation:** {}\n\n", + finding.recommendation + )); + if let Some(savings) = finding.estimated_savings_secs { + md.push_str(&format!( + "**Estimated savings:** {}/run\n\n", + format_duration(savings) + )); + } + } + } else { + md.push_str( + "## Findings\n\nNo significant bottlenecks detected. Your pipeline looks good!\n\n", + ); + } + + md.push_str("## Summary\n\n"); + md.push_str(&format!( + "| Metric | Value |\n|--------|-------|\n| Current Duration | {} |\n| Optimized Duration | {} |\n| Potential Savings | {:.1}% |\n", + format_duration(report.total_estimated_duration_secs), + format_duration(report.optimized_duration_secs), + report.potential_improvement_pct(), + )); + + if let Some(ref health) = report.health_score { + md.push_str(&format!( + "| Health Score | {:.0}/100 ({}) |\n", + health.total_score, + health.grade.label() + )); + } + md.push('\n'); + + md.push_str("---\n*Generated by [PipelineX](https://github.com/mackeh/PipelineX)*\n"); + + md +} + +/// Print lint report to terminal. +pub fn print_lint_report(report: &LintReport) { + println!(); + println!( + "{}", + format!( + " PipelineX Lint — {} ({})", + report.source_file, report.provider + ) + .bold() + ); + println!(); + + if report.findings.is_empty() { + println!(" {} No lint issues found!", "OK".green().bold()); + println!(); + return; + } + + for finding in &report.findings { + let tag = match finding.severity { + LintSeverity::Error => " ERROR ".on_red().white().bold().to_string(), + LintSeverity::Warning => " WARN ".on_yellow().black().bold().to_string(), + LintSeverity::Info => " INFO ".on_blue().white().to_string(), + }; + + print!( + " {} [{}] {}", + tag, + finding.rule_id.dimmed(), + finding.message + ); + if let Some(loc) = &finding.location { + print!(" ({})", loc.dimmed()); + } + println!(); + if let Some(suggestion) = &finding.suggestion { + println!(" {} {}", "Fix:".dimmed(), suggestion.cyan()); + } + } + + println!(); + println!( + " {} errors, {} warnings", + if report.errors > 0 { + report.errors.to_string().red().bold().to_string() + } else { + "0".to_string() + }, + if report.warnings > 0 { + report.warnings.to_string().yellow().to_string() + } else { + "0".to_string() + }, + ); + println!(); +} + +/// Print policy check report to terminal. +pub fn print_policy_report(report: &PolicyReport) { + println!(); + println!( + "{}", + format!(" PipelineX Policy Check — {}", report.source_file).bold() + ); + println!(); + + if report.violations.is_empty() { + println!(" {} All policy checks passed!", "PASS".green().bold()); + println!(); + return; + } + + for violation in &report.violations { + let tag = match violation.severity { + PolicySeverity::Error => " FAIL ".on_red().white().bold().to_string(), + PolicySeverity::Warning => " WARN ".on_yellow().black().bold().to_string(), + }; + + println!( + " {} [{}] {}", + tag, + violation.rule.dimmed(), + violation.message + ); + if !violation.affected_jobs.is_empty() { + println!( + " {} Jobs: {}", + "|".dimmed(), + violation.affected_jobs.join(", ").dimmed() + ); + } + } + + println!(); + let errors = report + .violations + .iter() + .filter(|v| v.severity == PolicySeverity::Error) + .count(); + let warnings = report + .violations + .iter() + .filter(|v| v.severity == PolicySeverity::Warning) + .count(); + println!( + " Result: {} ({} errors, {} warnings)", + if report.passed { + "PASS".green().bold().to_string() + } else { + "FAIL".red().bold().to_string() + }, + errors, + warnings, + ); + println!(); +} + +/// Print security scan results to terminal. +pub fn print_security_report(findings: &[Finding], source_file: &str) { + println!(); + println!( + "{}", + format!(" PipelineX Security Scan — {}", source_file).bold() + ); + println!(); + + if findings.is_empty() { + println!(" {} No security issues detected!", "OK".green().bold()); + println!(); + return; + } + + println!(" {}", "=".repeat(60).dimmed()); + println!(); + + for finding in findings { + print_finding(finding); + println!(); + } + + println!(" {}", "=".repeat(60).dimmed()); + println!(); + + let critical = findings + .iter() + .filter(|f| f.severity == Severity::Critical) + .count(); + let high = findings + .iter() + .filter(|f| f.severity == Severity::High) + .count(); + println!( + " {} security findings: {} critical, {} high, {} other", + findings.len(), + if critical > 0 { + critical.to_string().red().bold().to_string() + } else { + "0".to_string() + }, + if high > 0 { + high.to_string().yellow().bold().to_string() + } else { + "0".to_string() + }, + findings.len() - critical - high, + ); + println!(); +} + +/// Print comparison between two analysis reports. +pub fn print_comparison( + report_a: &AnalysisReport, + report_b: &AnalysisReport, + path_a: &str, + path_b: &str, +) { + println!(); + println!("{}", " PipelineX Compare".bold()); + println!(" A: {}", path_a.cyan()); + println!(" B: {}", path_b.cyan()); + println!(); + + println!(" {}", "Metric Comparison".bold().underline()); + println!( + " {:<30} {:>12} {:>12} {:>12}", + "Metric".underline(), + "A".underline(), + "B".underline(), + "Delta".underline() + ); + + let dur_a = report_a.total_estimated_duration_secs; + let dur_b = report_b.total_estimated_duration_secs; + let delta_dur = dur_b - dur_a; + let delta_str = if delta_dur > 0.0 { + format!("+{}", format_duration(delta_dur)).red().to_string() + } else if delta_dur < 0.0 { + format!("-{}", format_duration(-delta_dur)) + .green() + .to_string() + } else { + "0s".to_string() + }; + println!( + " {:<30} {:>12} {:>12} {:>12}", + "Est. Duration", + format_duration(dur_a), + format_duration(dur_b), + delta_str, + ); + + println!( + " {:<30} {:>12} {:>12} {:>12}", + "Jobs", + report_a.job_count, + report_b.job_count, + format_delta(report_b.job_count as i64 - report_a.job_count as i64), + ); + + println!( + " {:<30} {:>12} {:>12} {:>12}", + "Findings", + report_a.findings.len(), + report_b.findings.len(), + format_delta(report_b.findings.len() as i64 - report_a.findings.len() as i64), + ); + + println!( + " {:<30} {:>12} {:>12} {:>12}", + "Max Parallelism", + report_a.max_parallelism, + report_b.max_parallelism, + format_delta(report_b.max_parallelism as i64 - report_a.max_parallelism as i64), + ); + println!(); + + // Finding differences + let titles_a: std::collections::HashSet<_> = + report_a.findings.iter().map(|f| &f.title).collect(); + let titles_b: std::collections::HashSet<_> = + report_b.findings.iter().map(|f| &f.title).collect(); + + let new_in_b: Vec<_> = report_b + .findings + .iter() + .filter(|f| !titles_a.contains(&f.title)) + .collect(); + let removed_in_b: Vec<_> = report_a + .findings + .iter() + .filter(|f| !titles_b.contains(&f.title)) + .collect(); + + if !new_in_b.is_empty() { + println!(" {} New findings in B:", "NEW".on_red().white().bold()); + for f in &new_in_b { + println!(" {} [{}] {}", "+".green(), f.severity.symbol(), f.title); + } + println!(); + } + + if !removed_in_b.is_empty() { + println!(" {} Resolved in B:", "FIXED".on_green().white().bold()); + for f in &removed_in_b { + println!(" {} [{}] {}", "-".red(), f.severity.symbol(), f.title); + } + println!(); + } + + if new_in_b.is_empty() && removed_in_b.is_empty() { + println!(" Both configs have the same findings."); + println!(); + } +} + +fn format_delta(delta: i64) -> String { + if delta > 0 { + format!("+{}", delta) + } else if delta < 0 { + format!("{}", delta) + } else { + "0".to_string() + } +} + use pipelinex_core::providers::github_api::PipelineStatistics; pub fn print_history_stats(stats: &PipelineStatistics) { diff --git a/crates/pipelinex-cli/src/main.rs b/crates/pipelinex-cli/src/main.rs index 20bc6c6..3c4638c 100644 --- a/crates/pipelinex-cli/src/main.rs +++ b/crates/pipelinex-cli/src/main.rs @@ -1,7 +1,7 @@ mod display; use anyhow::{Context, Result}; -use clap::{Parser, Subcommand}; +use clap::{CommandFactory, Parser, Subcommand}; use pipelinex_core::analyzer; use pipelinex_core::flaky_detector::FlakyDetector; use pipelinex_core::github_actions_to_gitlab_ci; @@ -259,6 +259,101 @@ enum Commands { #[command(subcommand)] command: PluginCommands, }, + + /// Generate shell completions for Bash, Zsh, Fish, or PowerShell + Completions { + /// Shell to generate completions for + #[arg(value_enum)] + shell: clap_complete::Shell, + }, + + /// Auto-detect CI platform and generate initial configuration + Init { + /// Directory to scan for CI configs + #[arg(default_value = ".")] + path: PathBuf, + + /// Output path for generated config + #[arg(short, long, default_value = ".pipelinex/config.toml")] + output: PathBuf, + }, + + /// Compare two pipeline configurations + Compare { + /// First pipeline config file + file_a: PathBuf, + + /// Second pipeline config file + file_b: PathBuf, + + /// Output format (text, json) + #[arg(short, long, default_value = "text")] + format: String, + }, + + /// Watch pipeline configs for changes and re-analyze on save + Watch { + /// Path to watch (file or directory) + #[arg(default_value = ".github/workflows/")] + path: PathBuf, + + /// Output format for analysis + #[arg(short, long, default_value = "text")] + format: String, + }, + + /// Lint CI config for syntax errors, deprecations, and typos + Lint { + /// Path to workflow file or directory + #[arg(default_value = ".github/workflows/")] + path: PathBuf, + + /// Output format (text, json) + #[arg(short, long, default_value = "text")] + format: String, + }, + + /// Run security scan on pipeline configs (secrets, permissions, injection, supply chain) + Security { + /// Path to workflow file or directory + #[arg(default_value = ".github/workflows/")] + path: PathBuf, + + /// Output format (text, json) + #[arg(short, long, default_value = "text")] + format: String, + }, + + /// Check pipeline configs against organisational policy rules + Policy { + #[command(subcommand)] + command: PolicyCommands, + }, +} + +#[derive(Subcommand)] +enum PolicyCommands { + /// Check pipeline configs against a policy file + Check { + /// Path to workflow file or directory + #[arg(default_value = ".github/workflows/")] + path: PathBuf, + + /// Path to policy file + #[arg(short = 'c', long, default_value = ".pipelinex/policy.toml")] + policy: PathBuf, + + /// Output format (text, json) + #[arg(short, long, default_value = "text")] + format: String, + }, + + /// Generate a starter policy file + Init { + /// Path for the new policy file + #[arg(default_value = ".pipelinex/policy.toml")] + path: PathBuf, + }, } #[derive(Subcommand)] @@ -347,6 +442,21 @@ async fn main() -> Result<()> { Commands::MultiRepo { path, format } => cmd_multi_repo(&path, &format), Commands::RightSize { path, format } => cmd_right_size(&path, &format), Commands::Plugins { command } => cmd_plugins(command), + Commands::Completions { shell } => { + let mut cmd = Cli::command(); + clap_complete::generate(shell, &mut cmd, "pipelinex", &mut std::io::stdout()); + Ok(()) + } + Commands::Init { path, output } => cmd_init(&path, &output), + Commands::Compare { + file_a, + file_b, + format, + } => cmd_compare(&file_a, &file_b, &format), + Commands::Watch { path, format } => cmd_watch(&path, &format), + Commands::Lint { path, format } => cmd_lint(&path, &format), + Commands::Security { path, format } => cmd_security(&path, &format), + Commands::Policy { command } => cmd_policy(command), } } @@ -526,6 +636,9 @@ fn cmd_analyze(path: &Path, format: &str) -> Result<()> { pipelinex_core::analyzer::html_report::generate_html_report(&report, &dag); println!("{}", html); } + "markdown" | "md" => { + print!("{}", display::format_markdown_report(&report)); + } _ => { display::print_analysis_report(&report); } @@ -1244,6 +1357,354 @@ fn cmd_right_size(path: &Path, format: &str) -> Result<()> { Ok(()) } +fn cmd_init(scan_path: &Path, output: &Path) -> Result<()> { + println!("PipelineX Init — Scanning for CI configurations..."); + println!(); + + // Auto-detect CI platforms + let detections: Vec<(&str, &str)> = vec![ + (".github/workflows/", "github-actions"), + (".gitlab-ci.yml", "gitlab-ci"), + (".gitlab-ci.yaml", "gitlab-ci"), + ("Jenkinsfile", "jenkins"), + (".circleci/config.yml", "circleci"), + (".circleci/config.yaml", "circleci"), + ("bitbucket-pipelines.yml", "bitbucket"), + ("bitbucket-pipelines.yaml", "bitbucket"), + ("azure-pipelines.yml", "azure-pipelines"), + ("azure-pipelines.yaml", "azure-pipelines"), + (".buildkite/pipeline.yml", "buildkite"), + (".buildkite/pipeline.yaml", "buildkite"), + ]; + + let mut detected = Vec::new(); + for (path, provider) in &detections { + let full = scan_path.join(path); + if full.exists() { + detected.push((*provider, full)); + } + } + + if detected.is_empty() { + println!(" No CI configurations found in '{}'.", scan_path.display()); + println!(" Run this command from your project root directory."); + return Ok(()); + } + + println!(" Detected CI platforms:"); + let mut seen_providers = std::collections::HashSet::new(); + for (provider, path) in &detected { + if seen_providers.insert(*provider) { + println!(" - {} ({})", provider, path.display()); + } + } + println!(); + + // Generate config file + let primary_provider = detected[0].0; + let config_content = format!( + r#"# PipelineX Configuration +# Generated by `pipelinex init` + +[general] +provider = "{}" +severity_threshold = "medium" +output_format = "text" + +[cost] +runs_per_month = 500 +team_size = 10 +hourly_rate = 150.0 + +[analysis] +# Enable security scanning +security_scan = true +# Enable lint checking +lint = true +"#, + primary_provider, + ); + + // Create parent directory + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory '{}'", parent.display()))?; + } + + std::fs::write(output, &config_content) + .with_context(|| format!("Failed to write config to '{}'", output.display()))?; + + println!(" Config written to: {}", output.display()); + println!(); + println!(" Next steps:"); + println!(" pipelinex analyze — Analyze your pipelines"); + println!(" pipelinex lint — Lint your CI configs"); + println!(" pipelinex security — Run security scan"); + println!(); + + Ok(()) +} + +fn cmd_compare(file_a: &Path, file_b: &Path, format: &str) -> Result<()> { + if !file_a.is_file() { + anyhow::bail!("'{}' is not a file.", file_a.display()); + } + if !file_b.is_file() { + anyhow::bail!("'{}' is not a file.", file_b.display()); + } + + let dag_a = parse_pipeline(file_a)?; + let dag_b = parse_pipeline(file_b)?; + let report_a = analyzer::analyze(&dag_a); + let report_b = analyzer::analyze(&dag_b); + + match format { + "json" => { + #[derive(serde::Serialize)] + struct CompareOutput { + file_a: String, + file_b: String, + report_a: pipelinex_core::AnalysisReport, + report_b: pipelinex_core::AnalysisReport, + duration_delta_secs: f64, + findings_delta: i64, + } + + let output = CompareOutput { + file_a: file_a.display().to_string(), + file_b: file_b.display().to_string(), + duration_delta_secs: report_b.total_estimated_duration_secs + - report_a.total_estimated_duration_secs, + findings_delta: report_b.findings.len() as i64 - report_a.findings.len() as i64, + report_a, + report_b, + }; + println!("{}", serde_json::to_string_pretty(&output)?); + } + _ => { + display::print_comparison( + &report_a, + &report_b, + &file_a.display().to_string(), + &file_b.display().to_string(), + ); + } + } + + Ok(()) +} + +fn cmd_watch(path: &Path, format: &str) -> Result<()> { + use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + let format = format.to_string(); + let watch_path = if path.is_file() { + path.parent().unwrap_or(path).to_path_buf() + } else { + path.to_path_buf() + }; + + if !watch_path.exists() { + anyhow::bail!("Watch path '{}' does not exist", watch_path.display()); + } + + println!( + "PipelineX Watch — Monitoring {} for changes (Ctrl+C to stop)", + watch_path.display() + ); + println!(); + + // Do an initial analysis + let _ = run_analysis_for_watch(path, &format); + + let (tx, rx) = mpsc::channel::>(); + let mut watcher = + RecommendedWatcher::new(tx, Config::default()).context("Failed to create file watcher")?; + + watcher + .watch(&watch_path, RecursiveMode::Recursive) + .context("Failed to start watching")?; + + let mut last_run = Instant::now(); + let debounce = Duration::from_millis(500); + + for event in rx { + match event { + Ok(event) => { + let is_relevant = event.paths.iter().any(|p| { + let ext = p.extension().and_then(|e| e.to_str()); + matches!(ext, Some("yml") | Some("yaml") | Some("json")) + }); + + if is_relevant && last_run.elapsed() > debounce { + last_run = Instant::now(); + // Clear screen + print!("\x1b[2J\x1b[H"); + println!( + "[{}] Change detected, re-analysing...", + chrono::Local::now().format("%H:%M:%S") + ); + println!(); + let _ = run_analysis_for_watch(path, &format); + } + } + Err(e) => { + eprintln!("Watch error: {:?}", e); + } + } + } + + Ok(()) +} + +fn run_analysis_for_watch(path: &Path, format: &str) -> Result<()> { + let files = discover_workflow_files(path)?; + for file in &files { + match parse_pipeline(file) { + Ok(dag) => { + let report = analyzer::analyze(&dag); + match format { + "json" => { + let json = serde_json::to_string_pretty(&report)?; + println!("{}", json); + } + "markdown" | "md" => { + print!("{}", display::format_markdown_report(&report)); + } + _ => { + display::print_analysis_report(&report); + } + } + } + Err(e) => { + eprintln!("Error parsing {}: {}", file.display(), e); + } + } + } + Ok(()) +} + +fn cmd_lint(path: &Path, format: &str) -> Result<()> { + let files = discover_workflow_files(path)?; + + if files.is_empty() { + anyhow::bail!("No workflow files found at '{}'", path.display()); + } + + let mut exit_code = 0; + + for file in &files { + let content = std::fs::read_to_string(file) + .with_context(|| format!("Failed to read '{}'", file.display()))?; + + let dag = parse_pipeline(file)?; + let report = pipelinex_core::linter::lint(&content, &dag); + + if report.exit_code() > exit_code { + exit_code = report.exit_code(); + } + + match format { + "json" => { + let json = serde_json::to_string_pretty(&report)?; + println!("{}", json); + } + _ => { + display::print_lint_report(&report); + } + } + } + + if exit_code == 2 { + anyhow::bail!("Lint check failed with errors"); + } + + Ok(()) +} + +fn cmd_security(path: &Path, format: &str) -> Result<()> { + let files = discover_workflow_files(path)?; + + if files.is_empty() { + anyhow::bail!("No workflow files found at '{}'", path.display()); + } + + for file in &files { + let dag = parse_pipeline(file)?; + let findings = pipelinex_core::security::scan(&dag); + + match format { + "json" => { + let json = serde_json::to_string_pretty(&findings)?; + println!("{}", json); + } + _ => { + display::print_security_report(&findings, &file.display().to_string()); + } + } + } + + Ok(()) +} + +fn cmd_policy(command: PolicyCommands) -> Result<()> { + match command { + PolicyCommands::Init { path } => { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = pipelinex_core::policy::generate_default_policy(); + std::fs::write(&path, content)?; + println!("Policy file created: {}", path.display()); + println!("Edit this file to configure your organisation's CI policy rules."); + Ok(()) + } + PolicyCommands::Check { + path, + policy: policy_path, + format, + } => { + let policy = pipelinex_core::load_policy(&policy_path).with_context(|| { + format!("Failed to load policy from '{}'", policy_path.display()) + })?; + + let files = discover_workflow_files(&path)?; + if files.is_empty() { + anyhow::bail!("No workflow files found at '{}'", path.display()); + } + + let mut any_failed = false; + + for file in &files { + let dag = parse_pipeline(file)?; + let report = pipelinex_core::check_policy(&dag, &policy); + + if !report.passed { + any_failed = true; + } + + match format.as_str() { + "json" => { + let json = serde_json::to_string_pretty(&report)?; + println!("{}", json); + } + _ => { + display::print_policy_report(&report); + } + } + } + + if any_failed { + anyhow::bail!("Policy check failed"); + } + + Ok(()) + } + } +} + fn cmd_plugins(command: PluginCommands) -> Result<()> { match command { PluginCommands::Scaffold { path } => { diff --git a/crates/pipelinex-core/Cargo.toml b/crates/pipelinex-core/Cargo.toml index fccfc38..49ac178 100644 --- a/crates/pipelinex-core/Cargo.toml +++ b/crates/pipelinex-core/Cargo.toml @@ -18,3 +18,5 @@ quick-xml = { workspace = true } tokio = { workspace = true } reqwest = { workspace = true } chrono = { workspace = true } +strsim = { workspace = true } +toml = { workspace = true } diff --git a/crates/pipelinex-core/src/lib.rs b/crates/pipelinex-core/src/lib.rs index 4d420b4..045f32c 100644 --- a/crates/pipelinex-core/src/lib.rs +++ b/crates/pipelinex-core/src/lib.rs @@ -3,18 +3,22 @@ pub mod cost; pub mod flaky_detector; pub mod graph; pub mod health_score; +pub mod linter; pub mod migration; pub mod multi_repo; pub mod optimizer; pub mod parser; pub mod plugins; +pub mod policy; pub mod providers; pub mod runner_sizing; +pub mod security; pub mod simulator; pub mod test_selector; pub use analyzer::report::{AnalysisReport, Finding, Severity}; pub use flaky_detector::{FlakyCategory, FlakyDetector, FlakyReport, FlakyTest}; +pub use linter::{lint, LintReport}; pub use migration::{github_actions_to_gitlab_ci, MigrationResult}; pub use multi_repo::{analyze_multi_repo, MultiRepoReport, RepoPipeline}; pub use optimizer::Optimizer; @@ -30,5 +34,7 @@ pub use parser::jenkins::JenkinsParser; pub use plugins::{ list_external_optimizer_plugins, run_external_analyzer_plugins, scaffold_manifest, }; +pub use policy::{check_policy, load_policy, PolicyConfig, PolicyReport}; pub use runner_sizing::{profile_pipeline as profile_runner_sizing, RunnerSizingReport}; +pub use security::scan as security_scan; pub use test_selector::{TestSelection, TestSelector, TestSelectorConfig}; diff --git a/crates/pipelinex-core/src/linter/deprecation.rs b/crates/pipelinex-core/src/linter/deprecation.rs new file mode 100644 index 0000000..5b39185 --- /dev/null +++ b/crates/pipelinex-core/src/linter/deprecation.rs @@ -0,0 +1,171 @@ +use super::{LintFinding, LintSeverity}; +use crate::parser::dag::PipelineDag; + +struct DeprecationRule { + pattern: &'static str, + message: &'static str, + suggestion: &'static str, + severity: LintSeverity, +} + +const GITHUB_DEPRECATIONS: &[DeprecationRule] = &[ + DeprecationRule { + pattern: "actions/checkout@v2", + message: "actions/checkout@v2 is deprecated", + suggestion: "Upgrade to actions/checkout@v4", + severity: LintSeverity::Warning, + }, + DeprecationRule { + pattern: "actions/checkout@v3", + message: "actions/checkout@v3 is outdated", + suggestion: "Upgrade to actions/checkout@v4", + severity: LintSeverity::Info, + }, + DeprecationRule { + pattern: "actions/setup-node@v2", + message: "actions/setup-node@v2 is deprecated", + suggestion: "Upgrade to actions/setup-node@v4", + severity: LintSeverity::Warning, + }, + DeprecationRule { + pattern: "actions/setup-node@v3", + message: "actions/setup-node@v3 is outdated", + suggestion: "Upgrade to actions/setup-node@v4", + severity: LintSeverity::Info, + }, + DeprecationRule { + pattern: "actions/setup-python@v2", + message: "actions/setup-python@v2 is deprecated", + suggestion: "Upgrade to actions/setup-python@v5", + severity: LintSeverity::Warning, + }, + DeprecationRule { + pattern: "actions/upload-artifact@v2", + message: "actions/upload-artifact@v2 is deprecated and uses Node 12", + suggestion: "Upgrade to actions/upload-artifact@v4", + severity: LintSeverity::Warning, + }, + DeprecationRule { + pattern: "actions/upload-artifact@v3", + message: "actions/upload-artifact@v3 is outdated", + suggestion: "Upgrade to actions/upload-artifact@v4", + severity: LintSeverity::Info, + }, + DeprecationRule { + pattern: "actions/download-artifact@v2", + message: "actions/download-artifact@v2 is deprecated", + suggestion: "Upgrade to actions/download-artifact@v4", + severity: LintSeverity::Warning, + }, + DeprecationRule { + pattern: "actions/download-artifact@v3", + message: "actions/download-artifact@v3 is outdated", + suggestion: "Upgrade to actions/download-artifact@v4", + severity: LintSeverity::Info, + }, + DeprecationRule { + pattern: "actions/cache@v2", + message: "actions/cache@v2 is deprecated", + suggestion: "Upgrade to actions/cache@v4", + severity: LintSeverity::Warning, + }, +]; + +const GITLAB_DEPRECATIONS: &[DeprecationRule] = &[ + DeprecationRule { + pattern: "only:", + message: "The 'only' keyword is deprecated in GitLab CI", + suggestion: "Use 'rules:' syntax instead", + severity: LintSeverity::Warning, + }, + DeprecationRule { + pattern: "except:", + message: "The 'except' keyword is deprecated in GitLab CI", + suggestion: "Use 'rules:' syntax instead", + severity: LintSeverity::Warning, + }, +]; + +/// Check for deprecated actions, features, and patterns. +pub fn check_deprecations(dag: &PipelineDag) -> Vec { + let mut findings = Vec::new(); + + let rules = match dag.provider.as_str() { + "github-actions" => GITHUB_DEPRECATIONS, + "gitlab-ci" => GITLAB_DEPRECATIONS, + _ => return findings, + }; + + for node in dag.graph.node_weights() { + for step in &node.steps { + if let Some(uses) = &step.uses { + for rule in rules { + if uses.contains(rule.pattern) { + findings.push(LintFinding { + severity: rule.severity, + rule_id: "PLX-LINT-DEPR".to_string(), + message: format!( + "{} (job '{}', step '{}')", + rule.message, node.id, step.name + ), + suggestion: Some(rule.suggestion.to_string()), + location: Some(format!("jobs.{}.steps", node.id)), + }); + } + } + } + } + + // Check runner deprecation: suggest pinned version instead of -latest + if dag.provider == "github-actions" && node.runs_on.ends_with("-latest") { + findings.push(LintFinding { + severity: LintSeverity::Info, + rule_id: "PLX-LINT-RUNNER".to_string(), + message: format!( + "Job '{}' uses '{}' which auto-updates and may cause unexpected breaks", + node.id, node.runs_on + ), + suggestion: Some(format!( + "Consider pinning to a specific version (e.g., '{}')", + node.runs_on.replace("-latest", "-24.04") + )), + location: Some(format!("jobs.{}.runs-on", node.id)), + }); + } + } + + findings +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::dag::{JobNode, PipelineDag, StepInfo}; + + #[test] + fn test_detect_deprecated_checkout_v2() { + let mut dag = PipelineDag::new("ci".into(), "ci.yml".into(), "github-actions".into()); + let mut job = JobNode::new("build".into(), "Build".into()); + job.steps.push(StepInfo { + name: "Checkout".into(), + uses: Some("actions/checkout@v2".into()), + run: None, + estimated_duration_secs: None, + }); + dag.add_job(job); + + let findings = check_deprecations(&dag); + assert!(!findings.is_empty()); + assert!(findings[0].message.contains("deprecated")); + } + + #[test] + fn test_latest_runner_info() { + let mut dag = PipelineDag::new("ci".into(), "ci.yml".into(), "github-actions".into()); + let job = JobNode::new("build".into(), "Build".into()); + dag.add_job(job); + + let findings = check_deprecations(&dag); + assert!(findings.iter().any(|f| f.rule_id == "PLX-LINT-RUNNER")); + } +} diff --git a/crates/pipelinex-core/src/linter/mod.rs b/crates/pipelinex-core/src/linter/mod.rs new file mode 100644 index 0000000..cc5dd30 --- /dev/null +++ b/crates/pipelinex-core/src/linter/mod.rs @@ -0,0 +1,90 @@ +pub mod deprecation; +pub mod schema; +pub mod typo; + +use crate::parser::dag::PipelineDag; +use serde::{Deserialize, Serialize}; + +/// Severity for lint findings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum LintSeverity { + Error, + Warning, + Info, +} + +impl LintSeverity { + pub fn symbol(&self) -> &str { + match self { + LintSeverity::Error => "ERROR", + LintSeverity::Warning => "WARNING", + LintSeverity::Info => "INFO", + } + } +} + +/// A single lint finding. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LintFinding { + pub severity: LintSeverity, + pub rule_id: String, + pub message: String, + pub suggestion: Option, + pub location: Option, +} + +/// Complete lint report. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LintReport { + pub source_file: String, + pub provider: String, + pub findings: Vec, + pub errors: usize, + pub warnings: usize, +} + +impl LintReport { + pub fn exit_code(&self) -> i32 { + if self.errors > 0 { + 2 + } else if self.warnings > 0 { + 1 + } else { + 0 + } + } +} + +/// Run all lint checks on raw YAML content and parsed DAG. +pub fn lint(content: &str, dag: &PipelineDag) -> LintReport { + let mut findings = Vec::new(); + + // YAML syntax validation happens before this function is called + // (parsing would fail if YAML is invalid) + + // Deprecation checks + findings.extend(deprecation::check_deprecations(dag)); + + // Typo detection on raw YAML content + findings.extend(typo::check_typos(content, &dag.provider)); + + // Schema validation + findings.extend(schema::validate_schema(content, &dag.provider)); + + let errors = findings + .iter() + .filter(|f| f.severity == LintSeverity::Error) + .count(); + let warnings = findings + .iter() + .filter(|f| f.severity == LintSeverity::Warning) + .count(); + + LintReport { + source_file: dag.source_file.clone(), + provider: dag.provider.clone(), + findings, + errors, + warnings, + } +} diff --git a/crates/pipelinex-core/src/linter/schema.rs b/crates/pipelinex-core/src/linter/schema.rs new file mode 100644 index 0000000..4db7a58 --- /dev/null +++ b/crates/pipelinex-core/src/linter/schema.rs @@ -0,0 +1,175 @@ +use super::{LintFinding, LintSeverity}; + +/// Basic schema validation for CI configs. +pub fn validate_schema(content: &str, provider: &str) -> Vec { + let mut findings = Vec::new(); + + match provider { + "github-actions" => findings.extend(validate_github_actions(content)), + "gitlab-ci" => findings.extend(validate_gitlab_ci(content)), + _ => {} + } + + findings +} + +fn validate_github_actions(content: &str) -> Vec { + let mut findings = Vec::new(); + + // Check for required top-level keys + let yaml: Result = serde_yaml::from_str(content); + let yaml = match yaml { + Ok(v) => v, + Err(e) => { + findings.push(LintFinding { + severity: LintSeverity::Error, + rule_id: "PLX-LINT-YAML".to_string(), + message: format!("Invalid YAML: {}", e), + suggestion: None, + location: None, + }); + return findings; + } + }; + + // Must have 'on' trigger + // serde_yaml parses bare `on:` as boolean `true`, so also check for Value::Bool(true) key + let has_on = yaml.get("on").is_some() + || yaml + .as_mapping() + .is_some_and(|m| m.contains_key(serde_yaml::Value::Bool(true))); + if !has_on { + findings.push(LintFinding { + severity: LintSeverity::Error, + rule_id: "PLX-LINT-SCHEMA-001".to_string(), + message: "Missing required 'on' trigger block".to_string(), + suggestion: Some("Add 'on:' with push/pull_request triggers".to_string()), + location: Some("top-level".to_string()), + }); + } + + // Must have 'jobs' block + if yaml.get("jobs").is_none() { + findings.push(LintFinding { + severity: LintSeverity::Error, + rule_id: "PLX-LINT-SCHEMA-002".to_string(), + message: "Missing required 'jobs' block".to_string(), + suggestion: Some("Add 'jobs:' block with at least one job".to_string()), + location: Some("top-level".to_string()), + }); + } + + // Check jobs have runs-on + if let Some(jobs) = yaml.get("jobs").and_then(|v| v.as_mapping()) { + for (job_id, job_config) in jobs { + let job_name = job_id.as_str().unwrap_or("unknown"); + if job_config.get("runs-on").is_none() && job_config.get("uses").is_none() { + findings.push(LintFinding { + severity: LintSeverity::Error, + rule_id: "PLX-LINT-SCHEMA-003".to_string(), + message: format!( + "Job '{}' missing 'runs-on' or 'uses' (reusable workflow)", + job_name + ), + suggestion: Some("Add 'runs-on: ubuntu-latest' or equivalent".to_string()), + location: Some(format!("jobs.{}", job_name)), + }); + } + } + } + + findings +} + +fn validate_gitlab_ci(content: &str) -> Vec { + let mut findings = Vec::new(); + + let yaml: Result = serde_yaml::from_str(content); + let yaml = match yaml { + Ok(v) => v, + Err(e) => { + findings.push(LintFinding { + severity: LintSeverity::Error, + rule_id: "PLX-LINT-YAML".to_string(), + message: format!("Invalid YAML: {}", e), + suggestion: None, + location: None, + }); + return findings; + } + }; + + // Check that stages are defined if referenced + let has_stages = yaml.get("stages").is_some(); + if let Some(mapping) = yaml.as_mapping() { + for (key, value) in mapping { + let key_str = key.as_str().unwrap_or(""); + // Skip known top-level keys + if matches!( + key_str, + "stages" + | "variables" + | "image" + | "services" + | "before_script" + | "after_script" + | "default" + | "include" + | "workflow" + | "pages" + ) { + continue; + } + // This is likely a job definition + if let Some(stage) = value.get("stage").and_then(|v| v.as_str()) { + if !has_stages { + findings.push(LintFinding { + severity: LintSeverity::Warning, + rule_id: "PLX-LINT-SCHEMA-010".to_string(), + message: format!( + "Job '{}' references stage '{}' but no 'stages:' block is defined", + key_str, stage + ), + suggestion: Some("Add a 'stages:' block listing all stages".to_string()), + location: Some(format!("{}.stage", key_str)), + }); + } + } + } + } + + findings +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_missing_on_trigger() { + let content = "jobs:\n build:\n runs-on: ubuntu-latest\n"; + let findings = validate_github_actions(content); + assert!(findings.iter().any(|f| f.rule_id == "PLX-LINT-SCHEMA-001")); + } + + #[test] + fn test_missing_jobs() { + let content = "on: push\n"; + let findings = validate_github_actions(content); + assert!(findings.iter().any(|f| f.rule_id == "PLX-LINT-SCHEMA-002")); + } + + #[test] + fn test_missing_runs_on() { + let content = "on: push\njobs:\n build:\n steps:\n - run: echo hi\n"; + let findings = validate_github_actions(content); + assert!(findings.iter().any(|f| f.rule_id == "PLX-LINT-SCHEMA-003")); + } + + #[test] + fn test_valid_github_actions() { + let content = "on: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"; + let findings = validate_github_actions(content); + assert!(findings.is_empty()); + } +} diff --git a/crates/pipelinex-core/src/linter/typo.rs b/crates/pipelinex-core/src/linter/typo.rs new file mode 100644 index 0000000..9ccac0c --- /dev/null +++ b/crates/pipelinex-core/src/linter/typo.rs @@ -0,0 +1,192 @@ +use super::{LintFinding, LintSeverity}; + +const GITHUB_ACTIONS_KEYS: &[&str] = &[ + "name", + "on", + "jobs", + "runs-on", + "steps", + "uses", + "with", + "env", + "needs", + "if", + "strategy", + "matrix", + "services", + "container", + "outputs", + "permissions", + "concurrency", + "defaults", + "timeout-minutes", + "continue-on-error", + "runs", + "secrets", + "inputs", + "paths", + "paths-ignore", + "branches", + "branches-ignore", + "tags", + "tags-ignore", + "types", + "schedule", + "cron", + "workflow_dispatch", + "workflow_call", + "push", + "pull_request", + "pull_request_target", + "release", + "id", + "run", + "shell", + "working-directory", + "fail-fast", + "max-parallel", + "include", + "exclude", + "upload-artifact", + "download-artifact", + "cache", + "fetch-depth", + "node-version", + "python-version", + "java-version", + "go-version", + "group", + "cancel-in-progress", +]; + +const GITLAB_CI_KEYS: &[&str] = &[ + "stages", + "variables", + "image", + "services", + "before_script", + "after_script", + "script", + "stage", + "only", + "except", + "rules", + "when", + "allow_failure", + "needs", + "dependencies", + "artifacts", + "cache", + "coverage", + "retry", + "timeout", + "parallel", + "trigger", + "include", + "extends", + "tags", + "resource_group", + "environment", + "release", + "pages", + "interruptible", + "paths", + "expire_in", + "reports", + "untracked", + "key", + "policy", +]; + +/// Check for potential typos in YAML keys using edit distance. +pub fn check_typos(content: &str, provider: &str) -> Vec { + let known_keys = match provider { + "github-actions" => GITHUB_ACTIONS_KEYS, + "gitlab-ci" => GITLAB_CI_KEYS, + _ => return Vec::new(), + }; + + let mut findings = Vec::new(); + + for (line_num, line) in content.lines().enumerate() { + let trimmed = line.trim(); + // Skip comments and empty lines + if trimmed.starts_with('#') || trimmed.is_empty() || trimmed.starts_with('-') { + continue; + } + + // Extract the key (before the colon) + if let Some(colon_pos) = trimmed.find(':') { + let key = trimmed[..colon_pos] + .trim() + .trim_matches('"') + .trim_matches('\''); + + // Skip numeric keys, env var values, very short keys + if key.is_empty() || key.len() < 2 || key.chars().all(|c| c.is_numeric()) { + continue; + } + + // Skip keys that are known + if known_keys.contains(&key) { + continue; + } + + // Check if this looks like a job ID, step name, or env var (uppercase) + if key.chars().all(|c| c.is_uppercase() || c == '_') { + continue; + } + + // Find closest match + let mut best_match = None; + let mut best_distance = usize::MAX; + + for &known in known_keys { + let dist = strsim::damerau_levenshtein(key, known); + if dist < best_distance && dist <= 2 && dist > 0 { + best_distance = dist; + best_match = Some(known); + } + } + + if let Some(suggestion) = best_match { + findings.push(LintFinding { + severity: LintSeverity::Warning, + rule_id: "PLX-LINT-TYPO".to_string(), + message: format!("Possible typo: '{}' — did you mean '{}'?", key, suggestion), + suggestion: Some(format!("Replace '{}' with '{}'", key, suggestion)), + location: Some(format!("line {}", line_num + 1)), + }); + } + } + } + + findings +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_typo_neeed() { + let content = "jobs:\n build:\n neeed: [setup]\n"; + let findings = check_typos(content, "github-actions"); + assert!(!findings.is_empty()); + assert!(findings[0].message.contains("needs")); + } + + #[test] + fn test_no_false_positive_on_valid_keys() { + let content = "name: CI\non:\n push:\njobs:\n build:\n runs-on: ubuntu-latest\n"; + let findings = check_typos(content, "github-actions"); + assert!(findings.is_empty()); + } + + #[test] + fn test_detect_rns_on() { + let content = "jobs:\n build:\n rns-on: ubuntu-latest\n"; + let findings = check_typos(content, "github-actions"); + assert!(findings.iter().any(|f| f.message.contains("runs-on"))); + } +} diff --git a/crates/pipelinex-core/src/policy/mod.rs b/crates/pipelinex-core/src/policy/mod.rs new file mode 100644 index 0000000..d495909 --- /dev/null +++ b/crates/pipelinex-core/src/policy/mod.rs @@ -0,0 +1,318 @@ +use crate::parser::dag::PipelineDag; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// Policy configuration loaded from `.pipelinex/policy.toml`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PolicyConfig { + #[serde(default)] + pub rules: PolicyRules, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PolicyRules { + /// All actions must be pinned by SHA + #[serde(default)] + pub require_sha_pinning: bool, + + /// Banned runner labels (e.g., ["ubuntu-latest", "windows-latest"]) + #[serde(default)] + pub banned_runners: Vec, + + /// Require cache for these package managers (e.g., ["npm", "yarn", "pip", "cargo"]) + #[serde(default)] + pub require_cache: Vec, + + /// Maximum allowed pipeline duration in minutes + pub max_duration_minutes: Option, + + /// All workflows must have explicit permissions block + #[serde(default)] + pub require_permissions_block: bool, + + /// All workflows must have concurrency control + #[serde(default)] + pub require_concurrency: bool, + + /// Block secrets in env/run blocks + #[serde(default)] + pub block_hardcoded_secrets: bool, + + /// Minimum checkout version allowed (e.g., "v4") + pub min_checkout_version: Option, +} + +/// A policy violation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolicyViolation { + pub rule: String, + pub message: String, + pub affected_jobs: Vec, + pub severity: PolicySeverity, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PolicySeverity { + Error, + Warning, +} + +impl PolicySeverity { + pub fn symbol(&self) -> &str { + match self { + PolicySeverity::Error => "ERROR", + PolicySeverity::Warning => "WARN", + } + } +} + +/// Policy check result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolicyReport { + pub source_file: String, + pub violations: Vec, + pub passed: bool, +} + +/// Load policy configuration from a TOML file. +pub fn load_policy(path: &Path) -> anyhow::Result { + let content = std::fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("Failed to read policy file '{}': {}", path.display(), e))?; + let config: PolicyConfig = toml::from_str(&content) + .map_err(|e| anyhow::anyhow!("Failed to parse policy file '{}': {}", path.display(), e))?; + Ok(config) +} + +/// Check a pipeline DAG against a policy configuration. +pub fn check_policy(dag: &PipelineDag, policy: &PolicyConfig) -> PolicyReport { + let mut violations = Vec::new(); + + // Check SHA pinning + if policy.rules.require_sha_pinning { + let sha_re = regex::Regex::new(r"@[0-9a-f]{40}$").unwrap(); + for node in dag.graph.node_weights() { + for step in &node.steps { + if let Some(uses) = &step.uses { + if uses.starts_with("./") || uses.starts_with("docker://") { + continue; + } + if !sha_re.is_match(uses) { + violations.push(PolicyViolation { + rule: "require_sha_pinning".to_string(), + message: format!( + "Action '{}' in job '{}' is not pinned to a SHA", + uses, node.id + ), + affected_jobs: vec![node.id.clone()], + severity: PolicySeverity::Error, + }); + } + } + } + } + } + + // Check banned runners + if !policy.rules.banned_runners.is_empty() { + for node in dag.graph.node_weights() { + if policy.rules.banned_runners.contains(&node.runs_on) { + violations.push(PolicyViolation { + rule: "banned_runners".to_string(), + message: format!("Job '{}' uses banned runner '{}'", node.id, node.runs_on), + affected_jobs: vec![node.id.clone()], + severity: PolicySeverity::Error, + }); + } + } + } + + // Check max duration + if let Some(max_minutes) = policy.rules.max_duration_minutes { + let max_secs = max_minutes as f64 * 60.0; + for node in dag.graph.node_weights() { + if node.estimated_duration_secs > max_secs { + violations.push(PolicyViolation { + rule: "max_duration_minutes".to_string(), + message: format!( + "Job '{}' estimated duration ({:.0}s) exceeds max allowed ({}m)", + node.id, node.estimated_duration_secs, max_minutes + ), + affected_jobs: vec![node.id.clone()], + severity: PolicySeverity::Warning, + }); + } + } + } + + // Check require_cache + if !policy.rules.require_cache.is_empty() { + for node in dag.graph.node_weights() { + for pm in &policy.rules.require_cache { + let uses_pm = node.steps.iter().any(|s| { + if let Some(run) = &s.run { + match pm.as_str() { + "npm" => run.contains("npm ci") || run.contains("npm install"), + "yarn" => run.contains("yarn install") || run.contains("yarn --frozen"), + "pip" => run.contains("pip install"), + "cargo" => run.contains("cargo build") || run.contains("cargo test"), + _ => false, + } + } else { + false + } + }); + + if uses_pm && node.caches.is_empty() { + let has_cache_action = node + .steps + .iter() + .any(|s| s.uses.as_ref().is_some_and(|u| u.contains("cache"))); + if !has_cache_action { + violations.push(PolicyViolation { + rule: "require_cache".to_string(), + message: format!( + "Job '{}' uses {} but has no cache configured", + node.id, pm + ), + affected_jobs: vec![node.id.clone()], + severity: PolicySeverity::Error, + }); + } + } + } + } + } + + // Check require_concurrency (GitHub Actions specific) + if policy.rules.require_concurrency && dag.provider == "github-actions" { + // We check if the DAG name or env has concurrency info + // Since we don't parse concurrency block into DAG, check as best effort + let has_concurrency_env = dag.env.keys().any(|k| k.contains("concurrency")); + if !has_concurrency_env { + violations.push(PolicyViolation { + rule: "require_concurrency".to_string(), + message: "Workflow does not have a concurrency control block".to_string(), + affected_jobs: dag.job_ids(), + severity: PolicySeverity::Warning, + }); + } + } + + let passed = violations + .iter() + .all(|v| v.severity != PolicySeverity::Error); + + PolicyReport { + source_file: dag.source_file.clone(), + violations, + passed, + } +} + +/// Generate a starter policy file. +pub fn generate_default_policy() -> String { + r#"# PipelineX Policy Configuration +# See https://github.com/mackeh/PipelineX/docs/POLICIES.md + +[rules] +# Require all actions to be pinned by SHA +require_sha_pinning = false + +# Runners that are not allowed +banned_runners = [] + +# Require caching for these package managers +require_cache = [] + +# Maximum allowed pipeline duration (minutes) +# max_duration_minutes = 30 + +# Require explicit permissions block (GitHub Actions) +require_permissions_block = false + +# Require concurrency control +require_concurrency = false + +# Block hardcoded secrets in env/run blocks +block_hardcoded_secrets = true +"# + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::dag::{JobNode, PipelineDag, StepInfo}; + + fn make_test_dag() -> PipelineDag { + let mut dag = PipelineDag::new("ci".into(), "ci.yml".into(), "github-actions".into()); + let mut job = JobNode::new("build".into(), "Build".into()); + job.steps.push(StepInfo { + name: "Checkout".into(), + uses: Some("actions/checkout@v4".into()), + run: None, + estimated_duration_secs: None, + }); + job.steps.push(StepInfo { + name: "Build".into(), + uses: None, + run: Some("npm ci && npm run build".into()), + estimated_duration_secs: None, + }); + dag.add_job(job); + dag + } + + #[test] + fn test_sha_pinning_violation() { + let dag = make_test_dag(); + let policy = PolicyConfig { + rules: PolicyRules { + require_sha_pinning: true, + ..Default::default() + }, + }; + let report = check_policy(&dag, &policy); + assert!(!report.passed); + assert!(report + .violations + .iter() + .any(|v| v.rule == "require_sha_pinning")); + } + + #[test] + fn test_banned_runner() { + let dag = make_test_dag(); + let policy = PolicyConfig { + rules: PolicyRules { + banned_runners: vec!["ubuntu-latest".into()], + ..Default::default() + }, + }; + let report = check_policy(&dag, &policy); + assert!(!report.passed); + assert!(report.violations.iter().any(|v| v.rule == "banned_runners")); + } + + #[test] + fn test_require_cache_violation() { + let dag = make_test_dag(); + let policy = PolicyConfig { + rules: PolicyRules { + require_cache: vec!["npm".into()], + ..Default::default() + }, + }; + let report = check_policy(&dag, &policy); + assert!(!report.passed); + assert!(report.violations.iter().any(|v| v.rule == "require_cache")); + } + + #[test] + fn test_empty_policy_passes() { + let dag = make_test_dag(); + let policy = PolicyConfig::default(); + let report = check_policy(&dag, &policy); + assert!(report.passed); + } +} diff --git a/crates/pipelinex-core/src/security/injection.rs b/crates/pipelinex-core/src/security/injection.rs new file mode 100644 index 0000000..17be589 --- /dev/null +++ b/crates/pipelinex-core/src/security/injection.rs @@ -0,0 +1,118 @@ +use crate::analyzer::report::{Finding, FindingCategory, Severity}; +use crate::parser::dag::PipelineDag; + +/// Dangerous GitHub Actions expression contexts that can be attacker-controlled. +const DANGEROUS_CONTEXTS: &[&str] = &[ + "github.event.issue.title", + "github.event.issue.body", + "github.event.pull_request.title", + "github.event.pull_request.body", + "github.event.comment.body", + "github.event.review.body", + "github.event.head_commit.message", + "github.head_ref", + "github.event.workflow_run.head_branch", + "github.event.discussion.title", + "github.event.discussion.body", +]; + +/// Detect expression injection vulnerabilities in GitHub Actions workflows. +pub fn detect_injection(dag: &PipelineDag) -> Vec { + let mut findings = Vec::new(); + + // Primary check is for GitHub Actions + if dag.provider != "github-actions" { + return findings; + } + + for node in dag.graph.node_weights() { + for step in &node.steps { + if let Some(run) = &step.run { + for ctx in DANGEROUS_CONTEXTS { + let expression = format!("${{{{ {} }}}}", ctx); + if run.contains(&expression) { + findings.push(Finding { + severity: Severity::Critical, + category: FindingCategory::CustomPlugin, + title: format!("Expression injection via {}", ctx), + description: format!( + "Job '{}', step '{}' uses `{}` directly in a `run:` step. \ + This is attacker-controlled input and can lead to arbitrary code execution.", + node.id, step.name, ctx + ), + affected_jobs: vec![node.id.clone()], + recommendation: format!( + "Assign to an environment variable first:\n \ + env:\n SAFE_VALUE: ${{{{ {} }}}}\n \ + Then use $SAFE_VALUE in the run step.", + ctx + ), + fix_command: None, + estimated_savings_secs: None, + confidence: 0.95, + auto_fixable: false, + }); + } + } + } + } + } + + findings +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::dag::{JobNode, PipelineDag, StepInfo}; + + #[test] + fn test_detect_title_injection() { + let mut dag = PipelineDag::new("ci".into(), "ci.yml".into(), "github-actions".into()); + let mut job = JobNode::new("greet".into(), "Greet".into()); + job.steps.push(StepInfo { + name: "Echo title".into(), + uses: None, + run: Some("echo \"${{ github.event.issue.title }}\"".into()), + estimated_duration_secs: None, + }); + dag.add_job(job); + + let findings = detect_injection(&dag); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].severity, Severity::Critical); + assert!(findings[0].title.contains("injection")); + } + + #[test] + fn test_safe_context_not_flagged() { + let mut dag = PipelineDag::new("ci".into(), "ci.yml".into(), "github-actions".into()); + let mut job = JobNode::new("build".into(), "Build".into()); + job.steps.push(StepInfo { + name: "Use safe context".into(), + uses: None, + run: Some("echo ${{ github.sha }}".into()), + estimated_duration_secs: None, + }); + dag.add_job(job); + + let findings = detect_injection(&dag); + assert!(findings.is_empty()); + } + + #[test] + fn test_non_github_skipped() { + let mut dag = PipelineDag::new("ci".into(), "ci.yml".into(), "gitlab-ci".into()); + let mut job = JobNode::new("build".into(), "Build".into()); + job.steps.push(StepInfo { + name: "test".into(), + uses: None, + run: Some("echo \"${{ github.event.issue.title }}\"".into()), + estimated_duration_secs: None, + }); + dag.add_job(job); + + let findings = detect_injection(&dag); + assert!(findings.is_empty()); + } +} diff --git a/crates/pipelinex-core/src/security/mod.rs b/crates/pipelinex-core/src/security/mod.rs new file mode 100644 index 0000000..284a9cd --- /dev/null +++ b/crates/pipelinex-core/src/security/mod.rs @@ -0,0 +1,17 @@ +pub mod injection; +pub mod permissions; +pub mod secrets; +pub mod supply_chain; + +use crate::analyzer::report::Finding; +use crate::parser::dag::PipelineDag; + +/// Run all security scanners on a pipeline DAG. +pub fn scan(dag: &PipelineDag) -> Vec { + let mut findings = Vec::new(); + findings.extend(secrets::detect_secrets(dag)); + findings.extend(permissions::audit_permissions(dag)); + findings.extend(injection::detect_injection(dag)); + findings.extend(supply_chain::assess_supply_chain(dag)); + findings +} diff --git a/crates/pipelinex-core/src/security/permissions.rs b/crates/pipelinex-core/src/security/permissions.rs new file mode 100644 index 0000000..0b0d79a --- /dev/null +++ b/crates/pipelinex-core/src/security/permissions.rs @@ -0,0 +1,128 @@ +use crate::analyzer::report::{Finding, FindingCategory, Severity}; +use crate::parser::dag::PipelineDag; + +/// Audit workflow permissions for overly broad access. +pub fn audit_permissions(dag: &PipelineDag) -> Vec { + let mut findings = Vec::new(); + + // Only applicable to GitHub Actions + if dag.provider != "github-actions" { + return findings; + } + + // Check for write-all or broad permissions in workflow-level env + // Since we don't parse permissions block directly yet, check steps for + // indicators of missing or overly broad permissions + let has_permissions_indicator = dag.graph.node_weights().any(|job| { + job.env + .keys() + .any(|k| k.to_lowercase().contains("permissions")) + }); + + if !has_permissions_indicator { + // Check what actions are used to suggest minimal permissions + let mut needs_contents_write = false; + let mut needs_packages_write = false; + let mut needs_security_events_write = false; + let mut uses_third_party_with_token = false; + + for node in dag.graph.node_weights() { + for step in &node.steps { + if let Some(uses) = &step.uses { + if uses.contains("create-release") + || uses.contains("upload-release-asset") + || uses.contains("push") + { + needs_contents_write = true; + } + if uses.contains("docker/build-push-action") + || uses.contains("publish-packages") + { + needs_packages_write = true; + } + if uses.contains("codeql-action/upload-sarif") { + needs_security_events_write = true; + } + // Third-party actions that receive GITHUB_TOKEN + if !uses.starts_with("actions/") && !uses.starts_with("github/") { + uses_third_party_with_token = true; + } + } + } + } + + let mut suggested_perms = vec!["contents: read".to_string()]; + if needs_contents_write { + suggested_perms[0] = "contents: write".to_string(); + } + if needs_packages_write { + suggested_perms.push("packages: write".to_string()); + } + if needs_security_events_write { + suggested_perms.push("security-events: write".to_string()); + } + + findings.push(Finding { + severity: Severity::Medium, + category: FindingCategory::CustomPlugin, + title: "Missing explicit permissions block".to_string(), + description: "Workflow does not declare a permissions block. Without explicit permissions, the GITHUB_TOKEN may have broader access than needed.".to_string(), + affected_jobs: dag.job_ids(), + recommendation: format!( + "Add a permissions block to your workflow:\n permissions:\n {}", + suggested_perms.join("\n ") + ), + fix_command: None, + estimated_savings_secs: None, + confidence: 0.70, + auto_fixable: true, + }); + + if uses_third_party_with_token { + findings.push(Finding { + severity: Severity::Medium, + category: FindingCategory::CustomPlugin, + title: "GITHUB_TOKEN exposed to third-party actions".to_string(), + description: "Third-party actions have access to the GITHUB_TOKEN. Consider restricting token permissions to minimize risk.".to_string(), + affected_jobs: dag.job_ids(), + recommendation: "Pin third-party actions to full SHA commits and restrict permissions to the minimum required.".to_string(), + fix_command: None, + estimated_savings_secs: None, + confidence: 0.65, + auto_fixable: false, + }); + } + } + + findings +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::dag::{JobNode, PipelineDag, StepInfo}; + + #[test] + fn test_missing_permissions_detected() { + let mut dag = PipelineDag::new("ci".into(), "ci.yml".into(), "github-actions".into()); + let mut job = JobNode::new("build".into(), "Build".into()); + job.steps.push(StepInfo { + name: "Checkout".into(), + uses: Some("actions/checkout@v4".into()), + run: None, + estimated_duration_secs: None, + }); + dag.add_job(job); + + let findings = audit_permissions(&dag); + assert!(!findings.is_empty()); + assert!(findings.iter().any(|f| f.title.contains("permissions"))); + } + + #[test] + fn test_non_github_skipped() { + let dag = PipelineDag::new("ci".into(), "ci.yml".into(), "gitlab-ci".into()); + let findings = audit_permissions(&dag); + assert!(findings.is_empty()); + } +} diff --git a/crates/pipelinex-core/src/security/secrets.rs b/crates/pipelinex-core/src/security/secrets.rs new file mode 100644 index 0000000..adfbbad --- /dev/null +++ b/crates/pipelinex-core/src/security/secrets.rs @@ -0,0 +1,191 @@ +use crate::analyzer::report::{Finding, FindingCategory, Severity}; +use crate::parser::dag::PipelineDag; +use regex::Regex; + +struct SecretPattern { + id: &'static str, + description: &'static str, + regex: &'static str, + severity: Severity, +} + +const SECRET_PATTERNS: &[SecretPattern] = &[ + SecretPattern { + id: "PLX-SEC-001", + description: "Hardcoded API key or secret in env/run block", + regex: r#"(?i)(api[_-]?key|secret[_-]?key|access[_-]?key|auth[_-]?token|password)\s*[:=]\s*['"][A-Za-z0-9+/=_\-]{8,}['"]"#, + severity: Severity::Critical, + }, + SecretPattern { + id: "PLX-SEC-002", + description: "AWS Access Key ID detected", + regex: r#"AKIA[0-9A-Z]{16}"#, + severity: Severity::Critical, + }, + SecretPattern { + id: "PLX-SEC-003", + description: "GitHub Personal Access Token detected", + regex: r#"ghp_[A-Za-z0-9]{36}"#, + severity: Severity::Critical, + }, + SecretPattern { + id: "PLX-SEC-004", + description: "Docker login with inline password", + regex: r#"docker\s+login.*-p\s+\S+"#, + severity: Severity::Critical, + }, + SecretPattern { + id: "PLX-SEC-005", + description: "Base64-encoded secret piped to decode", + regex: r#"echo\s+[A-Za-z0-9+/=]{40,}\s*\|\s*base64"#, + severity: Severity::High, + }, + SecretPattern { + id: "PLX-SEC-006", + description: "Generic private key block", + regex: r#"-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----"#, + severity: Severity::Critical, + }, + SecretPattern { + id: "PLX-SEC-007", + description: "Slack webhook URL detected", + regex: r#"https://hooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]+"#, + severity: Severity::High, + }, +]; + +/// Detect hardcoded secrets in CI pipeline configurations. +pub fn detect_secrets(dag: &PipelineDag) -> Vec { + let mut findings = Vec::new(); + + for node in dag.graph.node_weights() { + // Check environment variables + for (key, value) in &node.env { + for pattern in SECRET_PATTERNS { + if let Ok(re) = Regex::new(pattern.regex) { + let check_str = format!("{}={}", key, value); + if re.is_match(&check_str) { + let redacted = redact_value(value); + findings.push(Finding { + severity: pattern.severity, + category: FindingCategory::CustomPlugin, + title: format!("Secret exposure: {}", pattern.description), + description: format!( + "Job '{}' env var '{}' contains what appears to be a hardcoded secret ({}...)", + node.id, key, redacted + ), + affected_jobs: vec![node.id.clone()], + recommendation: format!( + "Use CI secrets management instead of hardcoding. Move to ${{{{ secrets.{} }}}}", + key.to_uppercase() + ), + fix_command: None, + estimated_savings_secs: None, + confidence: 0.85, + auto_fixable: false, + }); + } + } + } + } + + // Check run steps + for step in &node.steps { + if let Some(run) = &step.run { + for pattern in SECRET_PATTERNS { + if let Ok(re) = Regex::new(pattern.regex) { + if re.is_match(run) { + findings.push(Finding { + severity: pattern.severity, + category: FindingCategory::CustomPlugin, + title: format!("Secret exposure: {}", pattern.description), + description: format!( + "Job '{}', step '{}' contains a potential hardcoded secret [{}]", + node.id, step.name, pattern.id + ), + affected_jobs: vec![node.id.clone()], + recommendation: "Remove hardcoded secrets. Use CI platform secrets management (e.g., GitHub Actions secrets, GitLab CI variables).".to_string(), + fix_command: None, + estimated_savings_secs: None, + confidence: 0.80, + auto_fixable: false, + }); + } + } + } + } + } + } + + findings +} + +fn redact_value(value: &str) -> String { + if value.len() <= 4 { + "****".to_string() + } else { + format!("{}****", &value[..4]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::dag::{JobNode, PipelineDag, StepInfo}; + use std::collections::HashMap; + + #[allow(dead_code)] + fn make_dag_with_env(env: HashMap) -> PipelineDag { + let mut dag = PipelineDag::new("test".into(), "test.yml".into(), "github-actions".into()); + let mut job = JobNode::new("build".into(), "Build".into()); + job.env = env; + dag.add_job(job); + dag + } + + fn make_dag_with_run(run_cmd: &str) -> PipelineDag { + let mut dag = PipelineDag::new("test".into(), "test.yml".into(), "github-actions".into()); + let mut job = JobNode::new("build".into(), "Build".into()); + job.steps.push(StepInfo { + name: "Run step".into(), + uses: None, + run: Some(run_cmd.into()), + estimated_duration_secs: None, + }); + dag.add_job(job); + dag + } + + #[test] + fn test_detect_aws_key() { + let dag = make_dag_with_run("export AWS_KEY=AKIAIOSFODNN7EXAMPLE"); + let findings = detect_secrets(&dag); + assert!(findings.iter().any(|f| f.title.contains("AWS Access Key"))); + } + + #[test] + fn test_detect_github_pat() { + let dag = make_dag_with_run( + "curl -H 'Authorization: token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij'", + ); + let findings = detect_secrets(&dag); + assert!(findings + .iter() + .any(|f| f.title.contains("GitHub Personal Access Token"))); + } + + #[test] + fn test_detect_docker_login() { + let dag = make_dag_with_run("docker login -u user -p mysecretpassword registry.io"); + let findings = detect_secrets(&dag); + assert!(findings.iter().any(|f| f.title.contains("Docker login"))); + } + + #[test] + fn test_no_false_positive_on_secrets_ref() { + let dag = make_dag_with_run("echo ${{ secrets.MY_TOKEN }}"); + let findings = detect_secrets(&dag); + // Should not flag ${{ secrets.* }} references as hardcoded secrets + assert!(findings.is_empty() || findings.iter().all(|f| f.confidence < 0.9)); + } +} diff --git a/crates/pipelinex-core/src/security/supply_chain.rs b/crates/pipelinex-core/src/security/supply_chain.rs new file mode 100644 index 0000000..c89caa9 --- /dev/null +++ b/crates/pipelinex-core/src/security/supply_chain.rs @@ -0,0 +1,219 @@ +use crate::analyzer::report::{Finding, FindingCategory, Severity}; +use crate::parser::dag::PipelineDag; +use regex::Regex; + +#[derive(Debug, Clone, PartialEq)] +enum PinningRisk { + Sha, // Pinned to full SHA — minimal risk + Tag, // Tag can be moved — medium risk + Branch, // Branch ref — high risk + Latest, // No version — critical risk + Unknown, +} + +impl PinningRisk { + fn severity(&self) -> Severity { + match self { + PinningRisk::Sha => Severity::Info, + PinningRisk::Tag => Severity::Low, + PinningRisk::Branch => Severity::High, + PinningRisk::Latest | PinningRisk::Unknown => Severity::High, + } + } + + fn label(&self) -> &str { + match self { + PinningRisk::Sha => "SHA-pinned", + PinningRisk::Tag => "tag-pinned", + PinningRisk::Branch => "branch-pinned", + PinningRisk::Latest => "unpinned (latest)", + PinningRisk::Unknown => "unknown version", + } + } +} + +fn classify_pinning(reference: &str) -> PinningRisk { + // Check for SHA pinning (40-char hex) + let sha_re = Regex::new(r"@[0-9a-f]{40}$").unwrap(); + if sha_re.is_match(reference) { + return PinningRisk::Sha; + } + + // Check for semver tag (v1, v1.2, v1.2.3) + let tag_re = Regex::new(r"@v?\d+(\.\d+)*$").unwrap(); + if tag_re.is_match(reference) { + return PinningRisk::Tag; + } + + // Check for branch name + let branch_re = Regex::new(r"@(main|master|develop|dev|release.*)$").unwrap(); + if branch_re.is_match(reference) { + return PinningRisk::Branch; + } + + // If there's an @ but nothing after, or no @ at all + if !reference.contains('@') { + return PinningRisk::Latest; + } + + PinningRisk::Unknown +} + +/// Known compromised or high-risk actions. +const KNOWN_RISKY_ACTIONS: &[(&str, &str)] = &[ + ( + "tj-actions/changed-files", + "Previously compromised (CVE-2023-51664). Pin to verified SHA.", + ), + ( + "reviewdog/action-setup", + "Previously targeted in supply chain attack. Verify SHA.", + ), +]; + +/// Assess supply chain risk for third-party actions and images. +pub fn assess_supply_chain(dag: &PipelineDag) -> Vec { + let mut findings = Vec::new(); + + for node in dag.graph.node_weights() { + for step in &node.steps { + if let Some(uses) = &step.uses { + // Skip built-in actions (actions/*, github/*) + let is_first_party = uses.starts_with("actions/") + || uses.starts_with("github/") + || uses.starts_with("./") + || uses.starts_with("docker://"); + + let pinning = classify_pinning(uses); + + // Check for known risky actions + for (risky_action, warning) in KNOWN_RISKY_ACTIONS { + if uses.contains(risky_action) { + findings.push(Finding { + severity: Severity::Critical, + category: FindingCategory::CustomPlugin, + title: format!("Known supply chain risk: {}", risky_action), + description: format!("Job '{}' uses '{}'. {}", node.id, uses, warning), + affected_jobs: vec![node.id.clone()], + recommendation: format!( + "Pin '{}' to a verified full SHA commit hash.", + risky_action + ), + fix_command: None, + estimated_savings_secs: None, + confidence: 0.95, + auto_fixable: false, + }); + } + } + + // Flag non-SHA-pinned third-party actions + if !is_first_party && pinning != PinningRisk::Sha { + findings.push(Finding { + severity: pinning.severity(), + category: FindingCategory::CustomPlugin, + title: format!( + "Third-party action {} is {}", + extract_action_name(uses), + pinning.label() + ), + description: format!( + "Job '{}' uses '{}' which is {}. Tags and branches can be moved by the action maintainer, potentially injecting malicious code.", + node.id, uses, pinning.label() + ), + affected_jobs: vec![node.id.clone()], + recommendation: format!( + "Pin to a full SHA: `{}@`. Find the SHA for the current tag on the action's releases page.", + extract_action_name(uses) + ), + fix_command: None, + estimated_savings_secs: None, + confidence: 0.90, + auto_fixable: false, + }); + } + } + } + } + + findings +} + +fn extract_action_name(uses: &str) -> &str { + uses.split('@').next().unwrap_or(uses) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::dag::{JobNode, PipelineDag, StepInfo}; + + #[test] + fn test_sha_pinned_ok() { + let pinning = classify_pinning("actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29"); + assert_eq!(pinning, PinningRisk::Sha); + } + + #[test] + fn test_tag_pinned() { + let pinning = classify_pinning("actions/checkout@v4"); + assert_eq!(pinning, PinningRisk::Tag); + } + + #[test] + fn test_branch_pinned() { + let pinning = classify_pinning("some/action@main"); + assert_eq!(pinning, PinningRisk::Branch); + } + + #[test] + fn test_third_party_tag_flagged() { + let mut dag = PipelineDag::new("ci".into(), "ci.yml".into(), "github-actions".into()); + let mut job = JobNode::new("build".into(), "Build".into()); + job.steps.push(StepInfo { + name: "Third party".into(), + uses: Some("some-org/some-action@v1".into()), + run: None, + estimated_duration_secs: None, + }); + dag.add_job(job); + + let findings = assess_supply_chain(&dag); + assert!(!findings.is_empty()); + assert!(findings.iter().any(|f| f.title.contains("tag-pinned"))); + } + + #[test] + fn test_first_party_not_flagged() { + let mut dag = PipelineDag::new("ci".into(), "ci.yml".into(), "github-actions".into()); + let mut job = JobNode::new("build".into(), "Build".into()); + job.steps.push(StepInfo { + name: "Checkout".into(), + uses: Some("actions/checkout@v4".into()), + run: None, + estimated_duration_secs: None, + }); + dag.add_job(job); + + let findings = assess_supply_chain(&dag); + assert!(findings.is_empty()); + } + + #[test] + fn test_known_risky_action() { + let mut dag = PipelineDag::new("ci".into(), "ci.yml".into(), "github-actions".into()); + let mut job = JobNode::new("build".into(), "Build".into()); + job.steps.push(StepInfo { + name: "Changed files".into(), + uses: Some("tj-actions/changed-files@v35".into()), + run: None, + estimated_duration_secs: None, + }); + dag.add_job(job); + + let findings = assess_supply_chain(&dag); + assert!(findings + .iter() + .any(|f| f.severity == Severity::Critical && f.title.contains("supply chain risk"))); + } +}