diff --git a/Cargo.lock b/Cargo.lock index d6ba12d..154a2d3 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 = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "1.3.2" @@ -106,6 +112,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -212,6 +227,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -228,6 +249,72 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -239,6 +326,31 @@ dependencies = [ "syn", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -270,6 +382,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.27" @@ -372,6 +490,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -432,6 +560,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.0" @@ -1029,20 +1163,34 @@ version = "2.2.0" dependencies = [ "anyhow", "chrono", + "ed25519-dalek", "glob", + "hex", "petgraph", "quick-xml", + "rand", "regex", "reqwest", "serde", "serde_json", "serde_yaml", "strsim", + "tempfile", "thiserror", "tokio", "toml", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -1058,6 +1206,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1092,6 +1249,36 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1193,6 +1380,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.3" @@ -1298,6 +1494,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1375,6 +1577,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1391,6 +1604,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + [[package]] name = "similar" version = "2.7.0" @@ -1419,6 +1641,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1704,6 +1936,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -1752,6 +1990,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -2134,6 +2378,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index fb60cbc..ad14f67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,3 +36,7 @@ clap_complete = "4" notify = { version = "7", features = ["macos_fsevent"] } toml = "0.8" strsim = "0.11" +ed25519-dalek = { version = "2", features = ["rand_core"] } +rand = "0.8" +hex = "0.4" +tempfile = "3" diff --git a/crates/pipelinex-cli/src/main.rs b/crates/pipelinex-cli/src/main.rs index 3c4638c..09136d1 100644 --- a/crates/pipelinex-cli/src/main.rs +++ b/crates/pipelinex-cli/src/main.rs @@ -41,9 +41,21 @@ enum Commands { #[arg(default_value = ".github/workflows/")] path: PathBuf, - /// Output format (text, json, sarif, html) + /// Output format (text, json, sarif, html, markdown) #[arg(short, long, default_value = "text")] format: String, + + /// Disable all network calls (offline mode for air-gapped environments) + #[arg(long)] + offline: bool, + + /// Redact sensitive information from output (for sharing with external parties) + #[arg(long)] + redact: bool, + + /// Sign the JSON output with an Ed25519 private key (hex or file path) + #[arg(long)] + sign: Option, }, /// Generate an optimized pipeline configuration @@ -329,6 +341,71 @@ enum Commands { #[command(subcommand)] command: PolicyCommands, }, + + /// Discover and analyze all CI configs across a monorepo + Monorepo { + /// Root directory to scan + #[arg(default_value = ".")] + path: PathBuf, + + /// Maximum directory depth to scan + #[arg(long, default_value = "5")] + depth: usize, + + /// Output format (text, json) + #[arg(short, long, default_value = "text")] + format: String, + }, + + /// Generate a CI Software Bill of Materials (CycloneDX SBOM) + Sbom { + /// Path to workflow file or directory + #[arg(default_value = ".github/workflows/")] + path: PathBuf, + + /// Output file (stdout if not specified) + #[arg(short, long)] + output: Option, + }, + + /// Generate a pipeline health score badge for READMEs + Badge { + /// Path to workflow file + path: PathBuf, + + /// Output format (markdown, json, url) + #[arg(short, long, default_value = "markdown")] + format: String, + }, + + /// Ed25519 key management for report signing + Keys { + #[command(subcommand)] + command: KeysCommands, + }, + + /// Verify a signed PipelineX report + Verify { + /// Path to signed report JSON file + report: PathBuf, + + /// Public key (hex string) or path to key file + #[arg(long)] + key: String, + }, + + /// Start MCP (Model Context Protocol) server for AI tool integration + McpServer, +} + +#[derive(Subcommand)] +enum KeysCommands { + /// Generate a new Ed25519 keypair for report signing + Generate { + /// Output directory for key files + #[arg(default_value = ".pipelinex")] + path: PathBuf, + }, } #[derive(Subcommand)] @@ -382,7 +459,13 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { - Commands::Analyze { path, format } => cmd_analyze(&path, &format), + Commands::Analyze { + path, + format, + offline: _offline, + redact, + sign, + } => cmd_analyze(&path, &format, redact, sign.as_deref()), Commands::Optimize { path, output, diff } => cmd_optimize(&path, output.as_deref(), diff), Commands::Diff { path } => cmd_diff(&path), Commands::Apply { @@ -457,6 +540,19 @@ async fn main() -> Result<()> { Commands::Lint { path, format } => cmd_lint(&path, &format), Commands::Security { path, format } => cmd_security(&path, &format), Commands::Policy { command } => cmd_policy(command), + Commands::Monorepo { + path, + depth, + format, + } => cmd_monorepo_discover(&path, depth, &format), + Commands::Sbom { path, output } => cmd_sbom(&path, output.as_deref()), + Commands::Badge { path, format } => cmd_badge(&path, &format), + Commands::Keys { command } => cmd_keys(command), + Commands::Verify { report, key } => cmd_verify(&report, &key), + Commands::McpServer => { + pipelinex_core::mcp::run_stdio_server()?; + Ok(()) + } } } @@ -606,7 +702,7 @@ fn discover_repo_pipeline_files(repo_root: &Path) -> Result> { Ok(files) } -fn cmd_analyze(path: &Path, format: &str) -> Result<()> { +fn cmd_analyze(path: &Path, format: &str, redact: bool, sign_key: Option<&str>) -> Result<()> { let files = discover_workflow_files(path)?; if files.is_empty() { @@ -619,12 +715,22 @@ fn cmd_analyze(path: &Path, format: &str) -> Result<()> { for file in &files { let dag = parse_pipeline(file)?; - let report = analyzer::analyze(&dag); + let mut report = analyzer::analyze(&dag); + + if redact { + report = pipelinex_core::redact::redact_report(&report); + } match format { "json" => { let json = serde_json::to_string_pretty(&report)?; - println!("{}", json); + if let Some(key) = sign_key { + let key_hex = read_key_material(key)?; + let signed = pipelinex_core::sign_report(&json, &key_hex)?; + println!("{}", serde_json::to_string_pretty(&signed)?); + } else { + println!("{}", json); + } } "sarif" => { let sarif = pipelinex_core::analyzer::sarif::to_sarif(&report); @@ -648,6 +754,22 @@ fn cmd_analyze(path: &Path, format: &str) -> Result<()> { Ok(()) } +fn read_key_material(key_or_path: &str) -> Result { + // If it looks like a hex key (64 chars, all hex), use directly + if key_or_path.len() == 64 && key_or_path.chars().all(|c| c.is_ascii_hexdigit()) { + return Ok(key_or_path.to_string()); + } + // Otherwise try to read as file + let path = Path::new(key_or_path); + if path.is_file() { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read key file: {}", path.display()))?; + Ok(content.trim().to_string()) + } else { + Ok(key_or_path.to_string()) + } +} + fn cmd_optimize(path: &PathBuf, output: Option<&std::path::Path>, show_diff: bool) -> Result<()> { if !path.is_file() { anyhow::bail!( @@ -1705,6 +1827,188 @@ fn cmd_policy(command: PolicyCommands) -> Result<()> { } } +fn cmd_monorepo_discover(path: &Path, max_depth: usize, format: &str) -> Result<()> { + let discovered = pipelinex_core::discovery::discover_monorepo(path, max_depth)?; + + if discovered.is_empty() { + anyhow::bail!( + "No CI pipeline files found under '{}' (depth {})", + path.display(), + max_depth + ); + } + + let summary = pipelinex_core::discovery::aggregate_discovery(path, &discovered); + + if format == "json" { + println!("{}", serde_json::to_string_pretty(&summary)?); + return Ok(()); + } + + println!("PipelineX Monorepo Discovery — {}", path.display()); + println!( + " Found {} pipeline files across {} packages", + summary.total_pipeline_files, + summary.packages.len() + ); + println!(); + + // Now analyze each discovered file + let mut total_findings = 0; + let mut total_jobs = 0; + + for pipeline in &discovered { + match parse_pipeline(&pipeline.file_path) { + Ok(dag) => { + let report = analyzer::analyze(&dag); + total_findings += report.findings.len(); + total_jobs += report.job_count; + println!( + " [{}] {} — {} jobs, {} findings", + pipeline.package_name, + pipeline.relative_path, + report.job_count, + report.findings.len() + ); + } + Err(e) => { + println!( + " [{}] {} — Error: {}", + pipeline.package_name, pipeline.relative_path, e + ); + } + } + } + + println!(); + println!( + " Total: {} jobs, {} findings across {} files", + total_jobs, + total_findings, + discovered.len() + ); + println!(); + + Ok(()) +} + +fn cmd_sbom(path: &Path, output: Option<&std::path::Path>) -> Result<()> { + let files = discover_workflow_files(path)?; + if files.is_empty() { + anyhow::bail!("No workflow files found at '{}'", path.display()); + } + + let mut dags = Vec::new(); + for file in &files { + dags.push(parse_pipeline(file)?); + } + + let dag_refs: Vec<&pipelinex_core::PipelineDag> = dags.iter().collect(); + let sbom = pipelinex_core::generate_sbom(&dag_refs); + let json = serde_json::to_string_pretty(&sbom)?; + + match output { + Some(out_path) => { + std::fs::write(out_path, &json)?; + println!("SBOM written to {}", out_path.display()); + println!( + " Components: {} | Format: CycloneDX {}", + sbom.components.len(), + sbom.spec_version + ); + } + None => { + println!("{}", json); + } + } + + Ok(()) +} + +fn cmd_badge(path: &Path, format: &str) -> Result<()> { + if !path.is_file() { + anyhow::bail!("'{}' is not a file.", path.display()); + } + + let dag = parse_pipeline(path)?; + let report = analyzer::analyze(&dag); + let badge = pipelinex_core::badge::generate_badge(&report); + + match format { + "json" => { + println!("{}", serde_json::to_string_pretty(&badge)?); + } + "url" => { + println!("{}", badge.shields_url); + } + _ => { + println!("{}", badge.markdown); + println!(); + println!( + " Score: {}/100 ({}) | {:.0}% optimized", + badge.score, badge.grade, badge.optimization_pct + ); + println!(); + println!(" Add the line above to your README.md"); + } + } + + Ok(()) +} + +fn cmd_keys(command: KeysCommands) -> Result<()> { + match command { + KeysCommands::Generate { path } => { + std::fs::create_dir_all(&path)?; + + let (private_key, public_key) = pipelinex_core::generate_keypair()?; + + let private_path = path.join("private.key"); + let public_path = path.join("public.key"); + + std::fs::write(&private_path, &private_key)?; + std::fs::write(&public_path, &public_key)?; + + println!("Ed25519 keypair generated:"); + println!(" Private key: {}", private_path.display()); + println!(" Public key: {}", public_path.display()); + println!(); + println!( + "Sign reports: pipelinex analyze ci.yml --format json --sign {}", + private_path.display() + ); + println!( + "Verify: pipelinex verify report.json --key {}", + public_path.display() + ); + println!(); + println!("Keep your private key secure. Share only the public key."); + + Ok(()) + } + } +} + +fn cmd_verify(report_path: &Path, key: &str) -> Result<()> { + let content = std::fs::read_to_string(report_path) + .with_context(|| format!("Failed to read report: {}", report_path.display()))?; + + let signed: pipelinex_core::signing::SignedReport = + serde_json::from_str(&content).context("Failed to parse signed report JSON")?; + + let public_key = read_key_material(key)?; + + let valid = pipelinex_core::verify_report(&signed, &public_key)?; + + if valid { + println!("Signature VALID — report is authentic and untampered."); + std::process::exit(0); + } else { + println!("Signature INVALID — report may have been tampered with!"); + std::process::exit(1); + } +} + 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 49ac178..cb697c0 100644 --- a/crates/pipelinex-core/Cargo.toml +++ b/crates/pipelinex-core/Cargo.toml @@ -20,3 +20,9 @@ reqwest = { workspace = true } chrono = { workspace = true } strsim = { workspace = true } toml = { workspace = true } +ed25519-dalek = { workspace = true } +rand = { workspace = true } +hex = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/pipelinex-core/src/badge.rs b/crates/pipelinex-core/src/badge.rs new file mode 100644 index 0000000..5fb846f --- /dev/null +++ b/crates/pipelinex-core/src/badge.rs @@ -0,0 +1,165 @@ +use crate::analyzer::report::{AnalysisReport, Severity}; +use serde::{Deserialize, Serialize}; + +/// Health score result for badge generation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BadgeInfo { + pub score: u8, + pub grade: String, + pub color: String, + pub optimization_pct: f64, + pub markdown: String, + pub shields_url: String, +} + +/// Calculate a pipeline health score and generate badge info. +pub fn generate_badge(report: &AnalysisReport) -> BadgeInfo { + let score = calculate_score(report); + let grade = score_to_grade(score); + let color = grade_to_color(&grade); + + let optimization_pct = if report.total_estimated_duration_secs > 0.0 { + ((report.total_estimated_duration_secs - report.optimized_duration_secs) + / report.total_estimated_duration_secs + * 100.0) + .clamp(0.0, 100.0) + } else { + 0.0 + }; + + let label = format!("PipelineX: {} | {}/100", grade, score); + let shields_url = format!( + "https://img.shields.io/badge/{}-{}-{}", + url_encode(&label), + url_encode(&format!("{:.0}% optimized", optimization_pct)), + color + ); + + let markdown = format!( + "[![PipelineX]({})](https://github.com/mackeh/PipelineX)", + shields_url + ); + + BadgeInfo { + score, + grade, + color, + optimization_pct, + markdown, + shields_url, + } +} + +fn calculate_score(report: &AnalysisReport) -> u8 { + let base: i32 = 100; + + let deductions: i32 = report + .findings + .iter() + .map(|f| match f.severity { + Severity::Critical => 25, + Severity::High => 10, + Severity::Medium => 3, + Severity::Low => 1, + Severity::Info => 0, + }) + .sum(); + + (base - deductions).clamp(0, 100) as u8 +} + +fn score_to_grade(score: u8) -> String { + match score { + 95..=100 => "A+".to_string(), + 85..=94 => "A".to_string(), + 70..=84 => "B".to_string(), + 50..=69 => "C".to_string(), + 25..=49 => "D".to_string(), + _ => "F".to_string(), + } +} + +fn grade_to_color(grade: &str) -> String { + match grade { + "A+" => "brightgreen".to_string(), + "A" => "green".to_string(), + "B" => "yellowgreen".to_string(), + "C" => "yellow".to_string(), + "D" => "orange".to_string(), + _ => "red".to_string(), + } +} + +fn url_encode(s: &str) -> String { + s.replace(' ', "%20") + .replace(':', "%3A") + .replace('|', "%7C") + .replace('/', "%2F") + .replace('%', "%25") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::report::{Finding, FindingCategory}; + + fn make_report(findings: Vec) -> AnalysisReport { + AnalysisReport { + pipeline_name: "CI".to_string(), + source_file: "ci.yml".to_string(), + provider: "github-actions".to_string(), + job_count: 3, + step_count: 10, + max_parallelism: 2, + critical_path: vec!["build".to_string(), "test".to_string()], + critical_path_duration_secs: 120.0, + total_estimated_duration_secs: 300.0, + optimized_duration_secs: 150.0, + findings, + health_score: None, + } + } + + #[test] + fn test_perfect_score() { + let report = make_report(vec![]); + let badge = generate_badge(&report); + assert_eq!(badge.score, 100); + assert_eq!(badge.grade, "A+"); + assert_eq!(badge.color, "brightgreen"); + } + + #[test] + fn test_score_with_critical() { + let report = make_report(vec![Finding { + severity: Severity::Critical, + category: FindingCategory::MissingCache, + title: "test".into(), + description: "test".into(), + affected_jobs: vec![], + recommendation: "test".into(), + fix_command: None, + estimated_savings_secs: None, + confidence: 0.9, + auto_fixable: false, + }]); + let badge = generate_badge(&report); + assert_eq!(badge.score, 75); + assert_eq!(badge.grade, "B"); + } + + #[test] + fn test_badge_markdown() { + let report = make_report(vec![]); + let badge = generate_badge(&report); + assert!(badge.markdown.contains("shields.io")); + assert!(badge.markdown.contains("PipelineX")); + } + + #[test] + fn test_optimization_pct() { + let report = make_report(vec![]); + let badge = generate_badge(&report); + assert!((badge.optimization_pct - 50.0).abs() < 0.1); + } +} diff --git a/crates/pipelinex-core/src/discovery.rs b/crates/pipelinex-core/src/discovery.rs new file mode 100644 index 0000000..372bce1 --- /dev/null +++ b/crates/pipelinex-core/src/discovery.rs @@ -0,0 +1,277 @@ +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; + +/// Result of discovering CI configs across a monorepo. +#[derive(Debug, Clone)] +pub struct DiscoveredPipeline { + pub package_name: String, + pub file_path: PathBuf, + pub relative_path: String, +} + +/// Monorepo discovery result. +#[derive(Debug, Clone, serde::Serialize)] +pub struct MonorepoDiscovery { + pub root: String, + pub packages: Vec, + pub total_pipeline_files: usize, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct PackageInfo { + pub name: String, + pub path: String, + pub pipeline_files: Vec, +} + +const CI_PATTERNS: &[&str] = &[ + ".github/workflows/*.yml", + ".github/workflows/*.yaml", + ".gitlab-ci.yml", + ".gitlab-ci.yaml", + "Jenkinsfile", + ".circleci/config.yml", + ".circleci/config.yaml", + "bitbucket-pipelines.yml", + "bitbucket-pipelines.yaml", + "azure-pipelines.yml", + "azure-pipelines.yaml", + ".buildkite/pipeline.yml", + ".buildkite/pipeline.yaml", + "codepipeline.yml", + "codepipeline.yaml", + "codepipeline.json", +]; + +/// Recursively discover CI pipeline files in a monorepo up to `max_depth` levels. +pub fn discover_monorepo(root: &Path, max_depth: usize) -> Result> { + if !root.exists() { + anyhow::bail!("Path '{}' does not exist", root.display()); + } + if !root.is_dir() { + anyhow::bail!("'{}' is not a directory", root.display()); + } + + let mut results = Vec::new(); + + // First, check root for CI configs + discover_at_path(root, root, &mut results)?; + + // Recurse into subdirectories + walk_dirs(root, root, 0, max_depth, &mut results)?; + + results.sort_by(|a, b| a.file_path.cmp(&b.file_path)); + results.dedup_by(|a, b| a.file_path == b.file_path); + + Ok(results) +} + +fn walk_dirs( + root: &Path, + current: &Path, + depth: usize, + max_depth: usize, + results: &mut Vec, +) -> Result<()> { + if depth >= max_depth { + return Ok(()); + } + + let entries = std::fs::read_dir(current) + .with_context(|| format!("Failed to read directory '{}'", current.display()))?; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + // Skip hidden dirs, build artifacts, and node_modules + if name_str.starts_with('.') + || name_str == "target" + || name_str == "node_modules" + || name_str == "vendor" + || name_str == "dist" + || name_str == "build" + || name_str == "__pycache__" + { + continue; + } + + discover_at_path(&path, root, results)?; + walk_dirs(root, &path, depth + 1, max_depth, results)?; + } + + Ok(()) +} + +fn discover_at_path(dir: &Path, root: &Path, results: &mut Vec) -> Result<()> { + let package_name = infer_package_name(dir, root); + + for pattern in CI_PATTERNS { + let full_pattern = format!("{}/{}", dir.display(), pattern); + if let Ok(entries) = glob::glob(&full_pattern) { + for entry in entries.flatten() { + if entry.is_file() { + let relative = entry + .strip_prefix(root) + .unwrap_or(&entry) + .display() + .to_string(); + results.push(DiscoveredPipeline { + package_name: package_name.clone(), + file_path: entry, + relative_path: relative, + }); + } + } + } + } + + // Check fixed-name files + for fixed in &[ + ".gitlab-ci.yml", + ".gitlab-ci.yaml", + "Jenkinsfile", + "bitbucket-pipelines.yml", + "bitbucket-pipelines.yaml", + "azure-pipelines.yml", + "azure-pipelines.yaml", + "codepipeline.yml", + "codepipeline.yaml", + "codepipeline.json", + ] { + let path = dir.join(fixed); + if path.is_file() { + let relative = path + .strip_prefix(root) + .unwrap_or(&path) + .display() + .to_string(); + results.push(DiscoveredPipeline { + package_name: package_name.clone(), + file_path: path, + relative_path: relative, + }); + } + } + + Ok(()) +} + +fn infer_package_name(dir: &Path, root: &Path) -> String { + if dir == root { + return "(root)".to_string(); + } + + // Try to read package name from common config files + // package.json + let pkg_json = dir.join("package.json"); + if pkg_json.is_file() { + if let Ok(content) = std::fs::read_to_string(&pkg_json) { + if let Ok(json) = serde_json::from_str::(&content) { + if let Some(name) = json.get("name").and_then(|n| n.as_str()) { + return name.to_string(); + } + } + } + } + + // Cargo.toml + let cargo_toml = dir.join("Cargo.toml"); + if cargo_toml.is_file() { + if let Ok(content) = std::fs::read_to_string(&cargo_toml) { + if let Ok(toml_val) = content.parse::() { + if let Some(name) = toml_val + .get("package") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + { + return name.to_string(); + } + } + } + } + + // Fall back to directory name + dir.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string() +} + +/// Aggregate discovery results into a structured report. +pub fn aggregate_discovery(root: &Path, pipelines: &[DiscoveredPipeline]) -> MonorepoDiscovery { + let mut packages: std::collections::HashMap> = + std::collections::HashMap::new(); + + for p in pipelines { + packages + .entry(p.package_name.clone()) + .or_default() + .push(p.relative_path.clone()); + } + + let mut package_infos: Vec = packages + .into_iter() + .map(|(name, files)| PackageInfo { + path: if name == "(root)" { + ".".to_string() + } else { + name.clone() + }, + name, + pipeline_files: files, + }) + .collect(); + package_infos.sort_by(|a, b| a.name.cmp(&b.name)); + + MonorepoDiscovery { + root: root.display().to_string(), + packages: package_infos, + total_pipeline_files: pipelines.len(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_discover_monorepo_nonexistent() { + let result = discover_monorepo(Path::new("/nonexistent/path"), 5); + assert!(result.is_err()); + } + + #[test] + fn test_discover_monorepo_empty_dir() { + let tmp = tempfile::tempdir().unwrap(); + let result = discover_monorepo(tmp.path(), 5).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_discover_monorepo_with_github_actions() { + let tmp = tempfile::tempdir().unwrap(); + let workflows = tmp.path().join(".github/workflows"); + fs::create_dir_all(&workflows).unwrap(); + fs::write(workflows.join("ci.yml"), "name: CI").unwrap(); + + let result = discover_monorepo(tmp.path(), 5).unwrap(); + assert!(!result.is_empty()); + assert!(result.iter().any(|p| p.relative_path.contains("ci.yml"))); + } + + #[test] + fn test_infer_package_name_from_dir() { + let tmp = tempfile::tempdir().unwrap(); + let name = infer_package_name(tmp.path(), tmp.path()); + assert_eq!(name, "(root)"); + } +} diff --git a/crates/pipelinex-core/src/lib.rs b/crates/pipelinex-core/src/lib.rs index 045f32c..894b9f5 100644 --- a/crates/pipelinex-core/src/lib.rs +++ b/crates/pipelinex-core/src/lib.rs @@ -1,9 +1,12 @@ pub mod analyzer; +pub mod badge; pub mod cost; +pub mod discovery; pub mod flaky_detector; pub mod graph; pub mod health_score; pub mod linter; +pub mod mcp; pub mod migration; pub mod multi_repo; pub mod optimizer; @@ -11,8 +14,11 @@ pub mod parser; pub mod plugins; pub mod policy; pub mod providers; +pub mod redact; pub mod runner_sizing; +pub mod sbom; pub mod security; +pub mod signing; pub mod simulator; pub mod test_selector; @@ -36,5 +42,7 @@ pub use plugins::{ }; pub use policy::{check_policy, load_policy, PolicyConfig, PolicyReport}; pub use runner_sizing::{profile_pipeline as profile_runner_sizing, RunnerSizingReport}; +pub use sbom::generate_sbom; pub use security::scan as security_scan; +pub use signing::{generate_keypair, sign_report, verify_report}; pub use test_selector::{TestSelection, TestSelector, TestSelectorConfig}; diff --git a/crates/pipelinex-core/src/mcp.rs b/crates/pipelinex-core/src/mcp.rs new file mode 100644 index 0000000..9ceea96 --- /dev/null +++ b/crates/pipelinex-core/src/mcp.rs @@ -0,0 +1,458 @@ +use crate::analyzer; +use crate::linter; +use crate::security; +use serde::{Deserialize, Serialize}; +use std::io::{BufRead, Write}; + +/// MCP tool definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpTool { + pub name: String, + pub description: String, + #[serde(rename = "inputSchema")] + pub input_schema: serde_json::Value, +} + +/// MCP JSON-RPC request. +#[derive(Debug, Clone, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub id: serde_json::Value, + pub method: String, + #[serde(default)] + pub params: serde_json::Value, +} + +/// MCP JSON-RPC response. +#[derive(Debug, Clone, Serialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct JsonRpcError { + pub code: i64, + pub message: String, +} + +/// Define available MCP tools. +pub fn list_tools() -> Vec { + vec![ + McpTool { + name: "pipelinex_analyze".to_string(), + description: "Analyze a CI/CD pipeline configuration for bottlenecks and optimization opportunities.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "yaml_content": { + "type": "string", + "description": "The YAML content of the CI/CD pipeline configuration" + }, + "provider": { + "type": "string", + "description": "CI provider (github-actions, gitlab-ci, circleci, etc.)", + "default": "github-actions" + } + }, + "required": ["yaml_content"] + }), + }, + McpTool { + name: "pipelinex_optimize".to_string(), + description: "Generate an optimized version of a CI/CD pipeline configuration.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "yaml_content": { + "type": "string", + "description": "The YAML content of the pipeline configuration to optimize" + }, + "provider": { + "type": "string", + "description": "CI provider", + "default": "github-actions" + } + }, + "required": ["yaml_content"] + }), + }, + McpTool { + name: "pipelinex_lint".to_string(), + description: "Lint a CI/CD pipeline configuration for syntax errors, deprecations, and typos.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "yaml_content": { + "type": "string", + "description": "The YAML content of the pipeline configuration to lint" + }, + "provider": { + "type": "string", + "description": "CI provider", + "default": "github-actions" + } + }, + "required": ["yaml_content"] + }), + }, + McpTool { + name: "pipelinex_security".to_string(), + description: "Scan a CI/CD pipeline configuration for security issues (secrets, permissions, injection, supply chain).".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "yaml_content": { + "type": "string", + "description": "The YAML content of the pipeline configuration to scan" + }, + "provider": { + "type": "string", + "description": "CI provider", + "default": "github-actions" + } + }, + "required": ["yaml_content"] + }), + }, + McpTool { + name: "pipelinex_cost".to_string(), + description: "Estimate CI/CD costs and potential savings for a pipeline configuration.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "yaml_content": { + "type": "string", + "description": "The YAML content of the pipeline configuration" + }, + "runs_per_month": { + "type": "number", + "description": "Estimated pipeline runs per month", + "default": 500 + }, + "provider": { + "type": "string", + "description": "CI provider", + "default": "github-actions" + } + }, + "required": ["yaml_content"] + }), + }, + ] +} + +fn parse_yaml_to_dag( + yaml_content: &str, + provider: &str, +) -> Result { + use crate::parser::github::GitHubActionsParser; + use crate::parser::gitlab::GitLabCIParser; + + match provider { + "github-actions" | "github" => { + GitHubActionsParser::parse_content(yaml_content, "mcp-input.yml") + .map_err(|e| format!("Failed to parse GitHub Actions YAML: {}", e)) + } + "gitlab-ci" | "gitlab" => GitLabCIParser::parse_content(yaml_content, "mcp-input.yml") + .map_err(|e| format!("Failed to parse GitLab CI YAML: {}", e)), + other => Err(format!( + "Unsupported provider '{}'. Use 'github-actions' or 'gitlab-ci'.", + other + )), + } +} + +/// Handle a single MCP tool call. +pub fn handle_tool_call( + tool_name: &str, + params: &serde_json::Value, +) -> Result { + let yaml_content = params + .get("yaml_content") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: yaml_content")?; + + let provider = params + .get("provider") + .and_then(|v| v.as_str()) + .unwrap_or("github-actions"); + + let dag = parse_yaml_to_dag(yaml_content, provider)?; + + match tool_name { + "pipelinex_analyze" => { + let report = analyzer::analyze(&dag); + serde_json::to_value(&report).map_err(|e| e.to_string()) + } + "pipelinex_optimize" => { + let report = analyzer::analyze(&dag); + // Return analysis with optimization suggestions + let result = serde_json::json!({ + "findings": report.findings.len(), + "current_duration_secs": report.total_estimated_duration_secs, + "optimized_duration_secs": report.optimized_duration_secs, + "improvement_pct": report.potential_improvement_pct(), + "recommendations": report.findings.iter().map(|f| { + serde_json::json!({ + "severity": format!("{:?}", f.severity), + "title": f.title, + "recommendation": f.recommendation, + "auto_fixable": f.auto_fixable, + }) + }).collect::>(), + }); + Ok(result) + } + "pipelinex_lint" => { + let lint_report = linter::lint(yaml_content, &dag); + serde_json::to_value(&lint_report).map_err(|e| e.to_string()) + } + "pipelinex_security" => { + let findings = security::scan(&dag); + serde_json::to_value(&findings).map_err(|e| e.to_string()) + } + "pipelinex_cost" => { + let report = analyzer::analyze(&dag); + let runs_per_month = params + .get("runs_per_month") + .and_then(|v| v.as_u64()) + .unwrap_or(500) as u32; + + let runner_type = dag + .graph + .node_weights() + .next() + .map(|j| j.runs_on.as_str()) + .unwrap_or("ubuntu-latest"); + + let estimate = crate::cost::estimate_costs( + report.total_estimated_duration_secs, + report.optimized_duration_secs, + runs_per_month, + runner_type, + 150.0, + 10, + ); + serde_json::to_value(&estimate).map_err(|e| e.to_string()) + } + other => Err(format!("Unknown tool: {}", other)), + } +} + +/// Run the MCP server on stdio, handling JSON-RPC messages. +pub fn run_stdio_server() -> anyhow::Result<()> { + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout(); + + for line in stdin.lock().lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + + let response = match serde_json::from_str::(&line) { + Ok(request) => process_request(&request), + Err(e) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::Value::Null, + result: None, + error: Some(JsonRpcError { + code: -32700, + message: format!("Parse error: {}", e), + }), + }, + }; + + let json = serde_json::to_string(&response)?; + writeln!(stdout, "{}", json)?; + stdout.flush()?; + } + + Ok(()) +} + +fn process_request(request: &JsonRpcRequest) -> JsonRpcResponse { + match request.method.as_str() { + "initialize" => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id.clone(), + result: Some(serde_json::json!({ + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "pipelinex", + "version": env!("CARGO_PKG_VERSION") + } + })), + error: None, + }, + "tools/list" => { + let tools = list_tools(); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id.clone(), + result: Some(serde_json::json!({ "tools": tools })), + error: None, + } + } + "tools/call" => { + let tool_name = request + .params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let arguments = request + .params + .get("arguments") + .cloned() + .unwrap_or(serde_json::json!({})); + + match handle_tool_call(tool_name, &arguments) { + Ok(result) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id.clone(), + result: Some(serde_json::json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string_pretty(&result).unwrap_or_default() + }] + })), + error: None, + }, + Err(e) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id.clone(), + result: Some(serde_json::json!({ + "content": [{ + "type": "text", + "text": format!("Error: {}", e) + }], + "isError": true + })), + error: None, + }, + } + } + _ => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id.clone(), + result: None, + error: Some(JsonRpcError { + code: -32601, + message: format!("Method not found: {}", request.method), + }), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_WORKFLOW: &str = r#" +name: CI +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: npm ci + - run: npm test +"#; + + #[test] + fn test_list_tools() { + let tools = list_tools(); + assert_eq!(tools.len(), 5); + assert!(tools.iter().any(|t| t.name == "pipelinex_analyze")); + assert!(tools.iter().any(|t| t.name == "pipelinex_lint")); + assert!(tools.iter().any(|t| t.name == "pipelinex_security")); + } + + #[test] + fn test_handle_analyze() { + let params = serde_json::json!({ + "yaml_content": SAMPLE_WORKFLOW, + "provider": "github-actions" + }); + let result = handle_tool_call("pipelinex_analyze", ¶ms); + assert!(result.is_ok()); + } + + #[test] + fn test_handle_lint() { + let params = serde_json::json!({ + "yaml_content": SAMPLE_WORKFLOW, + }); + let result = handle_tool_call("pipelinex_lint", ¶ms); + assert!(result.is_ok()); + } + + #[test] + fn test_handle_security() { + let params = serde_json::json!({ + "yaml_content": SAMPLE_WORKFLOW, + }); + let result = handle_tool_call("pipelinex_security", ¶ms); + assert!(result.is_ok()); + } + + #[test] + fn test_handle_unknown_tool() { + let params = serde_json::json!({ "yaml_content": "test" }); + let result = handle_tool_call("unknown_tool", ¶ms); + assert!(result.is_err()); + } + + #[test] + fn test_process_initialize() { + let request = JsonRpcRequest { + jsonrpc: "2.0".into(), + id: serde_json::json!(1), + method: "initialize".into(), + params: serde_json::json!({}), + }; + let response = process_request(&request); + assert!(response.result.is_some()); + assert!(response.error.is_none()); + } + + #[test] + fn test_process_tools_list() { + let request = JsonRpcRequest { + jsonrpc: "2.0".into(), + id: serde_json::json!(2), + method: "tools/list".into(), + params: serde_json::json!({}), + }; + let response = process_request(&request); + assert!(response.result.is_some()); + } + + #[test] + fn test_process_tools_call() { + let request = JsonRpcRequest { + jsonrpc: "2.0".into(), + id: serde_json::json!(3), + method: "tools/call".into(), + params: serde_json::json!({ + "name": "pipelinex_analyze", + "arguments": { + "yaml_content": SAMPLE_WORKFLOW, + "provider": "github-actions" + } + }), + }; + let response = process_request(&request); + assert!(response.result.is_some()); + assert!(response.error.is_none()); + } +} diff --git a/crates/pipelinex-core/src/parser/github.rs b/crates/pipelinex-core/src/parser/github.rs index 550b211..865472a 100644 --- a/crates/pipelinex-core/src/parser/github.rs +++ b/crates/pipelinex-core/src/parser/github.rs @@ -15,6 +15,11 @@ impl GitHubActionsParser { Self::parse(&content, path.to_string_lossy().to_string()) } + /// Parse GitHub Actions YAML content with a synthetic source file name. + pub fn parse_content(content: &str, source_name: &str) -> Result { + Self::parse(content, source_name.to_string()) + } + /// Parse GitHub Actions YAML content into a Pipeline DAG. pub fn parse(content: &str, source_file: String) -> Result { let yaml: Value = serde_yaml::from_str(content).context("Failed to parse YAML")?; diff --git a/crates/pipelinex-core/src/parser/gitlab.rs b/crates/pipelinex-core/src/parser/gitlab.rs index 978561a..18f91b1 100644 --- a/crates/pipelinex-core/src/parser/gitlab.rs +++ b/crates/pipelinex-core/src/parser/gitlab.rs @@ -30,6 +30,11 @@ impl GitLabCIParser { Self::parse(&content, path.to_string_lossy().to_string()) } + /// Parse GitLab CI YAML content with a synthetic source file name. + pub fn parse_content(content: &str, source_name: &str) -> Result { + Self::parse(content, source_name.to_string()) + } + /// Parse GitLab CI YAML content into a Pipeline DAG. pub fn parse(content: &str, source_file: String) -> Result { let yaml: Value = serde_yaml::from_str(content).context("Failed to parse YAML")?; diff --git a/crates/pipelinex-core/src/redact.rs b/crates/pipelinex-core/src/redact.rs new file mode 100644 index 0000000..70e6ab6 --- /dev/null +++ b/crates/pipelinex-core/src/redact.rs @@ -0,0 +1,123 @@ +use crate::analyzer::report::{AnalysisReport, Finding}; +use regex::Regex; + +/// Redact sensitive information from an analysis report. +pub fn redact_report(report: &AnalysisReport) -> AnalysisReport { + let mut redacted = report.clone(); + + // Redact source file to relative path only + redacted.source_file = redact_path(&redacted.source_file); + + // Redact findings + redacted.findings = redacted.findings.into_iter().map(redact_finding).collect(); + + // Redact critical path job names (keep structure, anonymize names) + // We keep the job names as they are structural, not sensitive + + redacted +} + +fn redact_finding(mut finding: Finding) -> Finding { + finding.description = redact_secrets_in_text(&finding.description); + finding.recommendation = redact_secrets_in_text(&finding.recommendation); + if let Some(cmd) = &finding.fix_command { + finding.fix_command = Some(redact_secrets_in_text(cmd)); + } + finding +} + +fn redact_path(path: &str) -> String { + // Strip absolute paths, keep only relative from project root + if let Some(idx) = path.rfind(".github/") { + return path[idx..].to_string(); + } + if let Some(idx) = path.rfind(".gitlab-ci") { + return path[idx..].to_string(); + } + if let Some(idx) = path.rfind(".circleci/") { + return path[idx..].to_string(); + } + if let Some(idx) = path.rfind(".buildkite/") { + return path[idx..].to_string(); + } + + // Generic: strip everything before the last component + std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("***") + .to_string() +} + +fn redact_secrets_in_text(text: &str) -> String { + let mut result = text.to_string(); + + // Redact secret names: secrets.FOO_BAR -> secrets.*** + let secrets_re = Regex::new(r"secrets\.([A-Za-z_][A-Za-z0-9_]*)").unwrap(); + result = secrets_re.replace_all(&result, "secrets.***").to_string(); + + // Redact URLs with authentication + let url_re = Regex::new(r"https?://[^\s]+@[^\s]+").unwrap(); + result = url_re + .replace_all(&result, "https://***@***/***") + .to_string(); + + // Redact internal-looking URLs (not github.com, gitlab.com, or bitbucket.org) + let url_all_re = Regex::new(r"https?://([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/[^\s]*").unwrap(); + result = url_all_re + .replace_all(&result, |caps: ®ex::Captures| { + let host = &caps[1]; + if host == "github.com" || host == "gitlab.com" || host == "bitbucket.org" { + caps[0].to_string() + } else { + "https://internal/***/***".to_string() + } + }) + .to_string(); + + // Redact anything that looks like a token/key value + let token_re = Regex::new(r"(?i)(token|key|secret|password)\s*[:=]\s*\S+").unwrap(); + result = token_re.replace_all(&result, "$1=***").to_string(); + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_redact_path_github() { + assert_eq!( + redact_path("/home/user/project/.github/workflows/ci.yml"), + ".github/workflows/ci.yml" + ); + } + + #[test] + fn test_redact_path_generic() { + assert_eq!(redact_path("/absolute/path/to/config.yml"), "config.yml"); + } + + #[test] + fn test_redact_secrets_in_text() { + let text = "Use ${{ secrets.MY_TOKEN }} instead"; + let redacted = redact_secrets_in_text(text); + assert!(redacted.contains("secrets.***")); + assert!(!redacted.contains("MY_TOKEN")); + } + + #[test] + fn test_redact_internal_urls() { + let text = "Deploy to https://internal.corp.com/api/deploy"; + let redacted = redact_secrets_in_text(text); + assert!(!redacted.contains("internal.corp.com")); + } + + #[test] + fn test_preserve_github_urls() { + let text = "See https://github.com/actions/checkout for details"; + let redacted = redact_secrets_in_text(text); + assert!(redacted.contains("github.com/actions/checkout")); + } +} diff --git a/crates/pipelinex-core/src/sbom.rs b/crates/pipelinex-core/src/sbom.rs new file mode 100644 index 0000000..e830059 --- /dev/null +++ b/crates/pipelinex-core/src/sbom.rs @@ -0,0 +1,234 @@ +use crate::parser::dag::PipelineDag; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// CycloneDX BOM format for CI pipeline components. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CiSbom { + pub bom_format: String, + pub spec_version: String, + pub version: u32, + pub metadata: SbomMetadata, + pub components: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SbomMetadata { + pub timestamp: String, + pub tools: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SbomTool { + pub vendor: String, + pub name: String, + pub version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct SbomComponent { + #[serde(rename = "type")] + pub component_type: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub purl: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// Generate a CycloneDX SBOM from one or more pipeline DAGs. +pub fn generate_sbom(dags: &[&PipelineDag]) -> CiSbom { + let mut components = BTreeSet::new(); + + for dag in dags { + for node in dag.graph.node_weights() { + for step in &node.steps { + if let Some(uses) = &step.uses { + if let Some(component) = parse_uses_to_component(uses) { + components.insert(component); + } + } + + // Extract Docker images from run steps + if let Some(run) = &step.run { + for component in extract_docker_images(run) { + components.insert(component); + } + } + } + + // Runner as a component + if !node.runs_on.is_empty() { + components.insert(SbomComponent { + component_type: "operating-system".to_string(), + name: node.runs_on.clone(), + version: None, + purl: None, + description: Some("CI runner image".to_string()), + }); + } + } + } + + CiSbom { + bom_format: "CycloneDX".to_string(), + spec_version: "1.5".to_string(), + version: 1, + metadata: SbomMetadata { + timestamp: chrono::Utc::now().to_rfc3339(), + tools: vec![SbomTool { + vendor: "PipelineX".to_string(), + name: "pipelinex".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }], + }, + components: components.into_iter().collect(), + } +} + +fn parse_uses_to_component(uses: &str) -> Option { + // Skip local actions and docker:// protocol + if uses.starts_with("./") { + return None; + } + + if uses.starts_with("docker://") { + let image = uses.strip_prefix("docker://")?; + let (name, version) = split_at_version(image); + return Some(SbomComponent { + component_type: "container".to_string(), + name: name.to_string(), + version: version.map(|v| v.to_string()), + purl: Some(format!( + "pkg:docker/{}{}", + name, + version.map(|v| format!("@{}", v)).unwrap_or_default() + )), + description: Some("Docker image used in CI step".to_string()), + }); + } + + // GitHub Action: owner/repo@ref + let (action, version) = if let Some(idx) = uses.find('@') { + (&uses[..idx], Some(&uses[idx + 1..])) + } else { + (uses, None) + }; + + Some(SbomComponent { + component_type: "application".to_string(), + name: action.to_string(), + version: version.map(|v| v.to_string()), + purl: Some(format!( + "pkg:github/{}{}", + action, + version.map(|v| format!("@{}", v)).unwrap_or_default() + )), + description: None, + }) +} + +fn split_at_version(image: &str) -> (&str, Option<&str>) { + if let Some(idx) = image.rfind(':') { + // Don't split on port-like patterns + let after = &image[idx + 1..]; + if after.contains('/') { + (image, None) + } else { + (&image[..idx], Some(after)) + } + } else { + (image, None) + } +} + +fn extract_docker_images(run: &str) -> Vec { + let mut components = Vec::new(); + let re = regex::Regex::new(r"docker\s+(?:run|pull|build\s+.*--from=)\s+([^\s]+)").unwrap(); + + for cap in re.captures_iter(run) { + let image = &cap[1]; + // Skip variable references + if image.contains('$') { + continue; + } + let (name, version) = split_at_version(image); + components.push(SbomComponent { + component_type: "container".to_string(), + name: name.to_string(), + version: version.map(|v| v.to_string()), + purl: Some(format!( + "pkg:docker/{}{}", + name, + version.map(|v| format!("@{}", v)).unwrap_or_default() + )), + description: Some("Docker image referenced in run step".to_string()), + }); + } + + components +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::dag::{JobNode, PipelineDag, StepInfo}; + + #[test] + fn test_parse_github_action() { + let component = parse_uses_to_component("actions/checkout@v4").unwrap(); + assert_eq!(component.name, "actions/checkout"); + assert_eq!(component.version.as_deref(), Some("v4")); + assert!(component.purl.unwrap().contains("pkg:github/")); + } + + #[test] + fn test_parse_docker_uses() { + let component = parse_uses_to_component("docker://node:20-slim").unwrap(); + assert_eq!(component.name, "node"); + assert_eq!(component.version.as_deref(), Some("20-slim")); + assert_eq!(component.component_type, "container"); + } + + #[test] + fn test_skip_local_action() { + let component = parse_uses_to_component("./.github/actions/my-action"); + assert!(component.is_none()); + } + + #[test] + fn test_generate_sbom() { + 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("docker run node:20 npm test".into()), + estimated_duration_secs: None, + }); + dag.add_job(job); + + let sbom = generate_sbom(&[&dag]); + assert_eq!(sbom.bom_format, "CycloneDX"); + assert!(!sbom.components.is_empty()); + assert!(sbom.components.iter().any(|c| c.name == "actions/checkout")); + } + + #[test] + fn test_extract_docker_images() { + let images = + extract_docker_images("docker run node:20-slim npm test && docker pull redis:7"); + assert_eq!(images.len(), 2); + assert!(images.iter().any(|c| c.name == "node")); + assert!(images.iter().any(|c| c.name == "redis")); + } +} diff --git a/crates/pipelinex-core/src/signing.rs b/crates/pipelinex-core/src/signing.rs new file mode 100644 index 0000000..996fe2c --- /dev/null +++ b/crates/pipelinex-core/src/signing.rs @@ -0,0 +1,118 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// A signed report envelope. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignedReport { + pub payload: String, + pub signature: String, + pub public_key: String, + pub algorithm: String, +} + +/// Generate an Ed25519 keypair as PEM-like hex strings. +pub fn generate_keypair() -> Result<(String, String)> { + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + + let signing_key = SigningKey::generate(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + + let private_hex = hex::encode(signing_key.to_bytes()); + let public_hex = hex::encode(verifying_key.to_bytes()); + + Ok((private_hex, public_hex)) +} + +/// Sign a JSON payload with an Ed25519 private key (hex-encoded). +pub fn sign_report(payload: &str, private_key_hex: &str) -> Result { + use ed25519_dalek::{Signer, SigningKey}; + + let key_bytes = hex::decode(private_key_hex).context("Invalid private key hex")?; + let key_array: [u8; 32] = key_bytes + .try_into() + .map_err(|_| anyhow::anyhow!("Private key must be 32 bytes"))?; + + let signing_key = SigningKey::from_bytes(&key_array); + let signature = signing_key.sign(payload.as_bytes()); + let public_hex = hex::encode(signing_key.verifying_key().to_bytes()); + + Ok(SignedReport { + payload: payload.to_string(), + signature: hex::encode(signature.to_bytes()), + public_key: public_hex, + algorithm: "Ed25519".to_string(), + }) +} + +/// Verify a signed report with a public key (hex-encoded). +pub fn verify_report(report: &SignedReport, public_key_hex: &str) -> Result { + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + + let key_bytes = hex::decode(public_key_hex).context("Invalid public key hex")?; + let key_array: [u8; 32] = key_bytes + .try_into() + .map_err(|_| anyhow::anyhow!("Public key must be 32 bytes"))?; + + let verifying_key = + VerifyingKey::from_bytes(&key_array).context("Invalid Ed25519 public key")?; + + let sig_bytes = hex::decode(&report.signature).context("Invalid signature hex")?; + let sig_array: [u8; 64] = sig_bytes + .try_into() + .map_err(|_| anyhow::anyhow!("Signature must be 64 bytes"))?; + + let signature = Signature::from_bytes(&sig_array); + + match verifying_key.verify(report.payload.as_bytes(), &signature) { + Ok(()) => Ok(true), + Err(_) => Ok(false), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_keypair() { + let (private_key, public_key) = generate_keypair().unwrap(); + assert_eq!(private_key.len(), 64); // 32 bytes * 2 hex chars + assert_eq!(public_key.len(), 64); + } + + #[test] + fn test_sign_and_verify() { + let (private_key, public_key) = generate_keypair().unwrap(); + let payload = r#"{"findings": [], "score": 95}"#; + + let signed = sign_report(payload, &private_key).unwrap(); + assert_eq!(signed.algorithm, "Ed25519"); + assert!(!signed.signature.is_empty()); + + let valid = verify_report(&signed, &public_key).unwrap(); + assert!(valid); + } + + #[test] + fn test_verify_tampered() { + let (private_key, public_key) = generate_keypair().unwrap(); + let payload = r#"{"findings": [], "score": 95}"#; + + let mut signed = sign_report(payload, &private_key).unwrap(); + signed.payload = r#"{"findings": [], "score": 100}"#.to_string(); // tampered + + let valid = verify_report(&signed, &public_key).unwrap(); + assert!(!valid); + } + + #[test] + fn test_verify_wrong_key() { + let (private_key, _) = generate_keypair().unwrap(); + let (_, other_public) = generate_keypair().unwrap(); + + let signed = sign_report("test", &private_key).unwrap(); + let valid = verify_report(&signed, &other_public).unwrap(); + assert!(!valid); + } +}