Skip to main content

forge_guard/ci/
mod.rs

1//! CI/CD integration — generates pipeline configurations for various platforms.
2
3use crate::core::ForgeGuardError;
4
5/// Generator for CI/CD pipeline configurations.
6pub struct CiGenerator {
7    platform: String,
8}
9
10impl CiGenerator {
11    /// Create a new CI generator for the given platform.
12    pub fn new(platform: &str) -> Self {
13        Self {
14            platform: platform.to_lowercase(),
15        }
16    }
17
18    /// Get the output filename for this CI platform.
19    pub fn filename(&self) -> &'static str {
20        match self.platform.as_str() {
21            "github" => "audit.yml",
22            "gitlab" => ".gitlab-ci.yml",
23            "bitbucket" => "bitbucket-pipelines.yml",
24            "azure" => "azure-pipelines.yml",
25            _ => "ci-config.yml",
26        }
27    }
28
29    /// Generate the CI configuration content.
30    pub fn generate(&self, include_deploy: bool) -> Result<String, ForgeGuardError> {
31        match self.platform.as_str() {
32            "github" => Ok(self.generate_github(include_deploy)),
33            "gitlab" => Ok(self.generate_gitlab(include_deploy)),
34            "bitbucket" => Ok(self.generate_bitbucket(include_deploy)),
35            "azure" => Ok(self.generate_azure(include_deploy)),
36            _ => Err(ForgeGuardError::Config(format!(
37                "Unsupported CI platform: {}. Supported: github, gitlab, bitbucket, azure",
38                self.platform
39            ))),
40        }
41    }
42
43    fn generate_github(&self, include_deploy: bool) -> String {
44        let mut yaml = String::from(
45            r#"name: Forge Guard Security Check
46
47on:
48  push:
49    branches: [ main, master, develop ]
50  pull_request:
51    branches: [ main, master ]
52
53env:
54  FOUNDRY_PROFILE: ci
55
56jobs:
57  security-audit:
58    runs-on: ubuntu-latest
59    steps:
60      - uses: actions/checkout@v4
61        with:
62          submodules: recursive
63
64      - name: Install Foundry
65        uses: foundry-rs/foundry-toolchain@v1
66        with:
67          version: nightly
68
69      - name: Install Forge Guard
70        run: |
71          cargo install forge-guard
72          forge audit --version
73
74      - name: Run Security Audit
75        run: forge audit --strict
76
77      - name: Run Fuzzing Campaign
78        run: forge fuzz --runs 10000
79
80      - name: Run Invariant Tests
81        run: forge invariant --runs 1000
82
83      - name: Check Dependencies
84        run: forge scan --depth 1
85
86      - name: Generate Report
87        run: forge audit --report --markdown
88"#,
89        );
90
91        if include_deploy {
92            yaml.push_str(
93                r#"
94  deploy:
95    runs-on: ubuntu-latest
96    needs: [security-audit]
97    if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'
98    steps:
99      - uses: actions/checkout@v4
100        with:
101          submodules: recursive
102
103      - name: Install Foundry
104        uses: foundry-rs/foundry-toolchain@v1
105        with:
106          version: nightly
107
108      - name: Final Security Check
109        run: forge audit --strict --production
110
111      - name: Safe Deploy
112        run: forge deploy-safe
113        env:
114          ETH_RPC_URL: ${{ secrets.ETH_RPC_URL }}
115          PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }}
116"#,
117            );
118        }
119
120        yaml
121    }
122
123    /// Generate a GitHub Actions workflow that publishes an SBOM on every
124    /// push to main/master. Used by `forge-guard sbom --ci` for supply-chain
125    /// compliance (EO 14028 / NTIA minimum elements).
126    pub fn generate_sbom_workflow(&self) -> String {
127        String::from(
128            r#"name: SBOM Generation
129
130on:
131  push:
132    branches: [ main, master ]
133  pull_request:
134    branches: [ main, master ]
135
136jobs:
137  sbom:
138    runs-on: ubuntu-latest
139    steps:
140      - uses: actions/checkout@v4
141        with:
142          submodules: recursive
143
144      - name: Install Forge Guard
145        run: |
146          cargo install forge-guard
147          forge-guard sbom --version
148
149      - name: Generate CycloneDX SBOM
150        run: forge-guard sbom --format cyclonedx --output sbom.cyclonedx.json
151
152      - name: Generate SPDX SBOM
153        run: forge-guard sbom --format spdx --output sbom.spdx.json
154
155      - name: Upload SBOM artifacts
156        uses: actions/upload-artifact@v4
157        with:
158          name: sbom
159          path: |
160            sbom.cyclonedx.json
161            sbom.spdx.json
162"#,
163        )
164    }
165
166    fn generate_gitlab(&self, include_deploy: bool) -> String {
167        let mut yaml = String::from(
168            r#"stages:
169  - security-audit
170  - fuzzing
171  - deploy
172
173variables:
174  FOUNDRY_PROFILE: ci
175
176cache:
177  key: ${CI_COMMIT_REF_SLUG}
178  paths:
179    - target/
180
181forge-guard:
182  stage: security-audit
183  image: ghcr.io/foundry-rs/foundry:latest
184  before_script:
185    - cargo install forge-guard || true
186  script:
187    - forge audit --strict
188    - forge scan --depth 1
189  artifacts:
190    paths:
191      - reports/
192    when: always
193
194fuzzing:
195  stage: fuzzing
196  image: ghcr.io/foundry-rs/foundry:latest
197  script:
198    - forge fuzz --runs 10000
199    - forge invariant --runs 1000
200"#,
201        );
202
203        if include_deploy {
204            yaml.push_str(
205                r#"
206deploy:
207  stage: deploy
208  image: ghcr.io/foundry-rs/foundry:latest
209  script:
210    - forge audit --strict --production
211    - forge deploy-safe
212  only:
213    - main
214  environment: production
215"#,
216            );
217        }
218
219        yaml
220    }
221
222    fn generate_bitbucket(&self, _include_deploy: bool) -> String {
223        String::from(
224            r#"image: ghcr.io/foundry-rs/foundry:latest
225
226pipelines:
227  default:
228    - step:
229        name: Security Audit
230        script:
231          - cargo install forge-guard || true
232          - forge audit --strict
233          - forge scan --depth 1
234          - forge fuzz --runs 10000
235        artifacts:
236          - reports/**
237
238  branches:
239    main:
240      - step:
241          name: Production Security Check
242          script:
243            - forge audit --strict --production
244            - forge deploy-safe
245          deployment: production
246"#,
247        )
248    }
249
250    fn generate_azure(&self, include_deploy: bool) -> String {
251        let mut yaml = String::from(
252            r#"trigger:
253  - main
254  - master
255
256pool:
257  vmImage: ubuntu-latest
258
259steps:
260  - checkout: self
261    submodules: recursive
262
263  - script: |
264      wget -q https://github.com/foundry-rs/foundry/releases/latest/download/foundry_linux_amd64.tar.gz
265      tar -xzf foundry_linux_amd64.tar.gz
266      export PATH=$PATH:$(pwd)
267      foundryup
268    displayName: 'Install Foundry'
269
270  - script: |
271      cargo install forge-guard
272    displayName: 'Install Forge Guard'
273
274  - script: |
275      forge audit --strict
276    displayName: 'Run Security Audit'
277
278  - script: |
279      forge fuzz --runs 10000
280    displayName: 'Run Fuzzing'
281
282  - script: |
283      forge invariant --runs 1000
284    displayName: 'Run Invariant Tests'
285
286  - script: |
287      forge scan --depth 1
288    displayName: 'Scan Dependencies'
289
290  - task: PublishBuildArtifacts@1
291    inputs:
292      pathToPublish: reports/
293      artifactName: 'audit-reports'
294"#,
295        );
296
297        if include_deploy {
298            yaml.push_str(
299                r#"
300  - script: |
301      forge audit --strict --production
302      forge deploy-safe
303    displayName: 'Safe Deploy'                env:
304      ETH_RPC_URL: $(ETH_RPC_URL)
305"#,
306            );
307        }
308
309        yaml
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn test_ci_generator_github() {
319        let gen = CiGenerator::new("github");
320        assert_eq!(gen.filename(), "audit.yml");
321
322        let config = gen.generate(false).unwrap();
323        assert!(config.contains("name: Forge Guard Security Check"));
324        assert!(config.contains("forge audit --strict"));
325        assert!(config.contains("forge fuzz --runs 10000"));
326    }
327
328    #[test]
329    fn test_ci_generator_github_with_deploy() {
330        let gen = CiGenerator::new("github");
331        let config = gen.generate(true).unwrap();
332        assert!(config.contains("forge deploy-safe"));
333        assert!(config.contains("needs: [security-audit]"));
334    }
335
336    #[test]
337    fn test_ci_generator_gitlab() {
338        let gen = CiGenerator::new("gitlab");
339        assert_eq!(gen.filename(), ".gitlab-ci.yml");
340
341        let config = gen.generate(false).unwrap();
342        assert!(config.contains("forge-guard:"));
343        assert!(config.contains("forge audit --strict"));
344    }
345
346    #[test]
347    fn test_ci_generator_bitbucket() {
348        let gen = CiGenerator::new("bitbucket");
349        assert_eq!(gen.filename(), "bitbucket-pipelines.yml");
350
351        let config = gen.generate(false).unwrap();
352        assert!(config.contains("pipelines:"));
353        assert!(config.contains("forge audit --strict"));
354    }
355
356    #[test]
357    fn test_ci_generator_azure() {
358        let gen = CiGenerator::new("azure");
359        assert_eq!(gen.filename(), "azure-pipelines.yml");
360
361        let config = gen.generate(false).unwrap();
362        assert!(config.contains("vmImage: ubuntu-latest"));
363        assert!(config.contains("forge audit --strict"));
364    }
365
366    #[test]
367    fn test_ci_generator_invalid_platform() {
368        let gen = CiGenerator::new("invalid");
369        assert!(gen.generate(false).is_err());
370    }
371
372    #[test]
373    fn test_ci_generator_filenames() {
374        assert_eq!(CiGenerator::new("github").filename(), "audit.yml");
375        assert_eq!(CiGenerator::new("gitlab").filename(), ".gitlab-ci.yml");
376        assert_eq!(
377            CiGenerator::new("bitbucket").filename(),
378            "bitbucket-pipelines.yml"
379        );
380        assert_eq!(CiGenerator::new("azure").filename(), "azure-pipelines.yml");
381        assert_eq!(CiGenerator::new("unknown").filename(), "ci-config.yml");
382    }
383
384    #[test]
385    fn test_ci_generator_case_insensitivity() {
386        assert_eq!(CiGenerator::new("GitHub").filename(), "audit.yml");
387        assert_eq!(CiGenerator::new("GITLAB").filename(), ".gitlab-ci.yml");
388        assert!(CiGenerator::new("GitHub").generate(false).is_ok());
389        assert!(CiGenerator::new("GITLAB").generate(false).is_ok());
390    }
391
392    #[test]
393    fn test_ci_generator_bitbucket_with_deploy() {
394        let gen = CiGenerator::new("bitbucket");
395        let config = gen.generate(true).unwrap();
396        assert!(config.contains("forge deploy-safe"));
397        assert!(config.contains("forge audit --strict --production"));
398        assert!(config.contains("deployment: production"));
399    }
400
401    #[test]
402    fn test_ci_generator_azure_with_deploy() {
403        let gen = CiGenerator::new("azure");
404        let config = gen.generate(true).unwrap();
405        assert!(config.contains("forge deploy-safe"));
406        assert!(config.contains("forge audit --strict --production"));
407        assert!(config.contains("ETH_RPC_URL"));
408    }
409
410    #[test]
411    fn test_ci_generator_gitlab_without_deploy_no_deploy_section() {
412        let gen = CiGenerator::new("gitlab");
413        let config = gen.generate(false).unwrap();
414        assert!(
415            !config.contains("deploy:"),
416            "Should not contain deploy section"
417        );
418        assert!(
419            !config.contains("forge deploy-safe"),
420            "Should not contain deploy-safe"
421        );
422    }
423
424    #[test]
425    fn test_ci_generator_output_not_empty_for_all() {
426        let platforms = ["github", "gitlab", "bitbucket", "azure"];
427        for platform in platforms {
428            let gen = CiGenerator::new(platform);
429            let config = gen.generate(true).unwrap();
430            assert!(!config.is_empty(), "{} should produce output", platform);
431        }
432    }
433
434    #[test]
435    fn test_ci_generator_unsupported_error_message() {
436        let gen = CiGenerator::new("circle-ci");
437        let err = gen.generate(false).unwrap_err();
438        let msg = err.to_string();
439        assert!(msg.contains("Unsupported CI platform"));
440        assert!(msg.contains("circle-ci"));
441        assert!(msg.contains("github"));
442        assert!(msg.contains("gitlab"));
443        assert!(msg.contains("azure"));
444    }
445
446    #[test]
447    fn test_ci_generator_github_contains_all_sections() {
448        let gen = CiGenerator::new("github");
449        let config = gen.generate(true).unwrap();
450        // Security audit job
451        assert!(config.contains("security-audit:"));
452        // Deploy job when included
453        assert!(config.contains("  deploy:"));
454        // Env vars for deploy
455        assert!(config.contains("ETH_RPC_URL"));
456        assert!(config.contains("DEPLOYER_PRIVATE_KEY"));
457    }
458
459    #[test]
460    fn test_ci_generator_sbom_workflow() {
461        let gen = CiGenerator::new("github");
462        let wf = gen.generate_sbom_workflow();
463        assert!(wf.contains("name: SBOM Generation"));
464        assert!(wf.contains("forge-guard sbom --format cyclonedx --output sbom.cyclonedx.json"));
465        assert!(wf.contains("forge-guard sbom --format spdx --output sbom.spdx.json"));
466        assert!(wf.contains("actions/upload-artifact@v4"));
467        assert!(wf.contains("sbom.cyclonedx.json"));
468    }
469
470    #[test]
471    fn test_ci_generator_sbom_workflow_schedule_trigger() {
472        let gen = CiGenerator::new("github");
473        let wf = gen.generate_sbom_workflow();
474        // Triggered on push and PR to main/master
475        assert!(wf.contains("branches: [ main, master ]"));
476        assert!(wf.contains("pull_request:"));
477    }
478
479    #[test]
480    fn test_ci_generator_platform_identity() {
481        let github = CiGenerator::new("github");
482        let gitlab = CiGenerator::new("gitlab");
483        let gh_config = github.generate(false).unwrap();
484        let gl_config = gitlab.generate(false).unwrap();
485        // GitHub uses GitHub Actions syntax, GitLab uses GitLab CI syntax
486        assert!(gh_config.contains("jobs:"));
487        assert!(gl_config.contains("stages:"));
488        assert!(gl_config.contains("forge-guard:"));
489    }
490}