Skip to main content

forge_guard/ci/
mod.rs

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