Skip to main content

agentshield/certify/
envelope.rs

1//! DSSE envelope and in-toto attestation payload types.
2//!
3//! Implements the DSSE specification (<https://github.com/secure-systems-lab/dsse>)
4//! wrapping an in-toto Statement v1 (<https://in-toto.io/Statement/v1>) with an
5//! AgentShield-specific predicate.
6
7use std::collections::HashMap;
8use std::path::Path;
9
10use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14use crate::ir::ScanTarget;
15use crate::rules::finding::Finding;
16use crate::rules::policy::Suppression;
17
18// ---------------------------------------------------------------------------
19// DSSE envelope
20// ---------------------------------------------------------------------------
21
22/// DSSE envelope per <https://github.com/secure-systems-lab/dsse>.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct DsseEnvelope {
25    #[serde(rename = "payloadType")]
26    pub payload_type: String,
27    /// Base64-encoded JSON payload.
28    pub payload: String,
29    pub signatures: Vec<DsseSignature>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct DsseSignature {
34    pub keyid: String,
35    /// Base64-encoded Ed25519 signature.
36    pub sig: String,
37}
38
39// ---------------------------------------------------------------------------
40// In-toto attestation payload
41// ---------------------------------------------------------------------------
42
43/// In-toto Statement v1 with an AgentShield predicate.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct AttestationPayload {
46    #[serde(rename = "_type")]
47    pub attestation_type: String,
48    pub subject: Vec<AttestationSubject>,
49    #[serde(rename = "predicateType")]
50    pub predicate_type: String,
51    pub predicate: ScanAttestation,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct AttestationSubject {
56    pub name: String,
57    pub digest: HashMap<String, String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ScanAttestation {
62    pub scanner: ScannerInfo,
63    pub findings: Vec<FindingSummary>,
64    pub suppressions: Vec<SuppressionSummary>,
65    pub capabilities: CapabilitySummary,
66    pub provenance: Option<ProvenanceSummary>,
67    pub egress_policy_hash: Option<String>,
68    pub scanned_at: String,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ScannerInfo {
73    pub name: String,
74    pub version: String,
75    pub rule_count: usize,
76    pub rules_version: String,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct FindingSummary {
81    pub fingerprint: String,
82    pub rule_id: String,
83    pub severity: String,
84    pub confidence: String,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SuppressionSummary {
89    pub fingerprint: String,
90    pub reason: String,
91    pub expires: Option<String>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct CapabilitySummary {
96    pub declared: Vec<String>,
97    pub observed: Vec<String>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ProvenanceSummary {
102    pub author: Option<String>,
103    pub repository: Option<String>,
104    pub license: Option<String>,
105}
106
107// ---------------------------------------------------------------------------
108// DSSE PAE (Pre-Authentication Encoding)
109// ---------------------------------------------------------------------------
110
111/// Compute DSSE PAE string for signing/verification.
112fn pae(payload_type: &str, payload: &str) -> String {
113    format!(
114        "DSSEv1 {} {} {} {}",
115        payload_type.len(),
116        payload_type,
117        payload.len(),
118        payload
119    )
120}
121
122// ---------------------------------------------------------------------------
123// DsseEnvelope implementation
124// ---------------------------------------------------------------------------
125
126impl DsseEnvelope {
127    /// Create an unsigned envelope from an attestation payload.
128    pub fn new(payload: &AttestationPayload) -> Result<Self, crate::error::ShieldError> {
129        let payload_json = serde_json::to_string(payload)?;
130        let payload_b64 = BASE64.encode(payload_json.as_bytes());
131
132        Ok(Self {
133            payload_type: "application/vnd.in-toto+json".to_string(),
134            payload: payload_b64,
135            signatures: vec![],
136        })
137    }
138
139    /// Sign the envelope with a 32-byte Ed25519 private key.
140    ///
141    /// Appends a new signature entry; can be called multiple times for
142    /// multi-party signing.
143    pub fn sign(&mut self, private_key_bytes: &[u8]) -> Result<(), crate::error::ShieldError> {
144        use ed25519_dalek::{Signer, SigningKey};
145
146        let key_array: [u8; 32] = private_key_bytes.try_into().map_err(|_| {
147            crate::error::ShieldError::Internal("Invalid key length: expected 32 bytes".to_string())
148        })?;
149        let signing_key = SigningKey::from_bytes(&key_array);
150
151        let pae_string = pae(&self.payload_type, &self.payload);
152        let signature = signing_key.sign(pae_string.as_bytes());
153        let public_key = signing_key.verifying_key();
154
155        self.signatures.push(DsseSignature {
156            keyid: hex::encode(public_key.as_bytes()),
157            sig: BASE64.encode(signature.to_bytes()),
158        });
159
160        Ok(())
161    }
162
163    /// Verify all signatures in the envelope.
164    ///
165    /// Returns `Ok(false)` if the envelope is unsigned.
166    /// Returns `Err` if any signature is invalid.
167    pub fn verify(&self) -> Result<bool, crate::error::ShieldError> {
168        if self.signatures.is_empty() {
169            return Ok(false);
170        }
171
172        use ed25519_dalek::{Signature, Verifier, VerifyingKey};
173
174        let pae_string = pae(&self.payload_type, &self.payload);
175
176        for sig in &self.signatures {
177            let key_bytes = hex::decode(&sig.keyid).map_err(|e| {
178                crate::error::ShieldError::Internal(format!("Invalid keyid hex: {}", e))
179            })?;
180            let key_array: [u8; 32] = key_bytes.as_slice().try_into().map_err(|_| {
181                crate::error::ShieldError::Internal("Invalid key length".to_string())
182            })?;
183            let verifying_key = VerifyingKey::from_bytes(&key_array).map_err(|e| {
184                crate::error::ShieldError::Internal(format!("Invalid verifying key: {}", e))
185            })?;
186
187            let sig_bytes = BASE64.decode(&sig.sig).map_err(|e| {
188                crate::error::ShieldError::Internal(format!("Invalid signature base64: {}", e))
189            })?;
190            let sig_array: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| {
191                crate::error::ShieldError::Internal("Invalid signature length".to_string())
192            })?;
193            let signature = Signature::from_bytes(&sig_array);
194
195            verifying_key
196                .verify(pae_string.as_bytes(), &signature)
197                .map_err(|e| {
198                    crate::error::ShieldError::Internal(format!(
199                        "Signature verification failed: {}",
200                        e
201                    ))
202                })?;
203        }
204
205        Ok(true)
206    }
207
208    /// Decode and deserialize the payload from the envelope.
209    pub fn decode_payload(&self) -> Result<AttestationPayload, crate::error::ShieldError> {
210        let payload_bytes = BASE64.decode(&self.payload).map_err(|e| {
211            crate::error::ShieldError::Internal(format!("Invalid payload base64: {}", e))
212        })?;
213        let payload: AttestationPayload = serde_json::from_slice(&payload_bytes)?;
214        Ok(payload)
215    }
216}
217
218// ---------------------------------------------------------------------------
219// Attestation builder
220// ---------------------------------------------------------------------------
221
222/// Build an in-toto attestation payload from scan results.
223pub fn build_attestation(
224    scan_root: &Path,
225    findings: &[Finding],
226    suppressions: &[Suppression],
227    targets: &[ScanTarget],
228    egress_policy_hash: Option<String>,
229) -> AttestationPayload {
230    // Subject: the scanned directory name + its hash
231    let dir_name = scan_root
232        .file_name()
233        .map(|n| n.to_string_lossy().to_string())
234        .unwrap_or_else(|| "unknown".to_string());
235
236    let mut dir_hasher = Sha256::new();
237    dir_hasher.update(dir_name.as_bytes());
238    let dir_hash = hex::encode(dir_hasher.finalize());
239
240    let subject = AttestationSubject {
241        name: dir_name,
242        digest: [("sha256".to_string(), dir_hash)].into_iter().collect(),
243    };
244
245    // Summarize findings
246    let finding_summaries: Vec<FindingSummary> = findings
247        .iter()
248        .map(|f| FindingSummary {
249            fingerprint: f.fingerprint(scan_root),
250            rule_id: f.rule_id.clone(),
251            severity: format!("{:?}", f.severity),
252            confidence: format!("{:?}", f.confidence),
253        })
254        .collect();
255
256    // Summarize suppressions
257    let suppression_summaries: Vec<SuppressionSummary> = suppressions
258        .iter()
259        .map(|s| SuppressionSummary {
260            fingerprint: s.fingerprint.clone(),
261            reason: s.reason.clone(),
262            expires: s.expires.clone(),
263        })
264        .collect();
265
266    // Build capability summary from all targets
267    let mut declared = Vec::new();
268    let mut observed = Vec::new();
269    for target in targets {
270        for tool in &target.tools {
271            for perm in &tool.declared_permissions {
272                declared.push(format!("{:?}", perm.permission_type));
273            }
274        }
275        if !target.execution.commands.is_empty() {
276            observed.push("ProcessExec".to_string());
277        }
278        if !target.execution.network_operations.is_empty() {
279            observed.push("NetworkAccess".to_string());
280        }
281        if !target.execution.file_operations.is_empty() {
282            observed.push("FileAccess".to_string());
283        }
284        if !target.execution.dynamic_exec.is_empty() {
285            observed.push("DynamicExec".to_string());
286        }
287    }
288    declared.sort();
289    declared.dedup();
290    observed.sort();
291    observed.dedup();
292
293    // Provenance from first target
294    let provenance = targets.first().map(|t| ProvenanceSummary {
295        author: t.provenance.author.clone(),
296        repository: t.provenance.repository.clone(),
297        license: t.provenance.license.clone(),
298    });
299
300    // Scanner info
301    let rule_count = crate::rules::RuleEngine::new().list_rules().len();
302    let scanner = ScannerInfo {
303        name: "agentshield".to_string(),
304        version: env!("CARGO_PKG_VERSION").to_string(),
305        rule_count,
306        rules_version: "v0.5".to_string(),
307    };
308
309    AttestationPayload {
310        attestation_type: "https://in-toto.io/Statement/v1".to_string(),
311        subject: vec![subject],
312        predicate_type: "https://agentshield.dev/attestation/v1".to_string(),
313        predicate: ScanAttestation {
314            scanner,
315            findings: finding_summaries,
316            suppressions: suppression_summaries,
317            capabilities: CapabilitySummary { declared, observed },
318            provenance,
319            egress_policy_hash,
320            scanned_at: chrono::Utc::now().to_rfc3339(),
321        },
322    }
323}
324
325// ---------------------------------------------------------------------------
326// Tests
327// ---------------------------------------------------------------------------
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::ir::SourceLocation;
333    use crate::rules::finding::{AttackCategory, Confidence, Evidence, Severity};
334    use std::path::PathBuf;
335
336    /// Helper: build a minimal finding for tests.
337    fn make_test_finding(rule_id: &str, file: &str, evidence_desc: &str) -> Finding {
338        Finding {
339            rule_id: rule_id.to_string(),
340            rule_name: "Test Rule".to_string(),
341            severity: Severity::High,
342            confidence: Confidence::High,
343            attack_category: AttackCategory::CommandInjection,
344            message: "test finding".to_string(),
345            location: Some(SourceLocation {
346                file: PathBuf::from(file),
347                line: 10,
348                column: 0,
349                end_line: None,
350                end_column: None,
351            }),
352            evidence: vec![Evidence {
353                description: evidence_desc.to_string(),
354                location: None,
355                snippet: None,
356            }],
357            taint_path: None,
358            remediation: Some("Fix it".to_string()),
359            cwe_id: Some("CWE-78".to_string()),
360        }
361    }
362
363    #[test]
364    fn test_attestation_payload_serialization() {
365        let scan_root = Path::new("/project");
366        let findings = vec![make_test_finding(
367            "SHIELD-001",
368            "/project/src/main.py",
369            "subprocess.run receives parameter",
370        )];
371        let suppressions = vec![Suppression {
372            fingerprint: "abc123".to_string(),
373            reason: "False positive".to_string(),
374            expires: Some("2099-12-31".to_string()),
375            created_at: None,
376        }];
377
378        let payload = build_attestation(scan_root, &findings, &suppressions, &[], None);
379
380        let json = serde_json::to_string_pretty(&payload).expect("serialize payload");
381
382        // Verify structure
383        let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse JSON");
384        assert_eq!(
385            parsed["_type"].as_str().unwrap(),
386            "https://in-toto.io/Statement/v1"
387        );
388        assert_eq!(
389            parsed["predicateType"].as_str().unwrap(),
390            "https://agentshield.dev/attestation/v1"
391        );
392        assert_eq!(parsed["subject"].as_array().unwrap().len(), 1);
393        assert_eq!(parsed["predicate"]["findings"].as_array().unwrap().len(), 1);
394        assert_eq!(
395            parsed["predicate"]["suppressions"]
396                .as_array()
397                .unwrap()
398                .len(),
399            1
400        );
401        assert_eq!(
402            parsed["predicate"]["scanner"]["name"].as_str().unwrap(),
403            "agentshield"
404        );
405        assert!(parsed["predicate"]["scanned_at"].as_str().is_some());
406
407        // Finding summary has expected fields
408        let fs = &parsed["predicate"]["findings"][0];
409        assert_eq!(fs["rule_id"].as_str().unwrap(), "SHIELD-001");
410        assert!(fs["fingerprint"].as_str().is_some());
411    }
412
413    #[test]
414    fn test_unsigned_envelope() {
415        let scan_root = Path::new("/project");
416        let payload = build_attestation(scan_root, &[], &[], &[], None);
417        let envelope = DsseEnvelope::new(&payload).expect("create envelope");
418
419        assert_eq!(envelope.payload_type, "application/vnd.in-toto+json");
420        assert!(envelope.signatures.is_empty());
421
422        // Should serialize to valid JSON
423        let json = serde_json::to_string(&envelope).expect("serialize envelope");
424        let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse JSON");
425        assert_eq!(
426            parsed["payloadType"].as_str().unwrap(),
427            "application/vnd.in-toto+json"
428        );
429        assert!(parsed["signatures"].as_array().unwrap().is_empty());
430
431        // Verify returns false for unsigned
432        assert!(!envelope.verify().expect("verify unsigned"));
433    }
434
435    #[test]
436    fn test_sign_and_verify() {
437        let scan_root = Path::new("/project");
438        let findings = vec![make_test_finding(
439            "SHIELD-001",
440            "/project/src/main.py",
441            "subprocess.run receives parameter",
442        )];
443        let payload = build_attestation(scan_root, &findings, &[], &[], None);
444        let mut envelope = DsseEnvelope::new(&payload).expect("create envelope");
445
446        // Generate a deterministic test key
447        let private_key: [u8; 32] = [42u8; 32];
448        envelope.sign(&private_key).expect("sign envelope");
449
450        assert_eq!(envelope.signatures.len(), 1);
451        assert!(!envelope.signatures[0].keyid.is_empty());
452        assert!(!envelope.signatures[0].sig.is_empty());
453
454        // Verification should pass
455        assert!(envelope.verify().expect("verify signed"));
456
457        // Decoded payload should match original
458        let decoded = envelope.decode_payload().expect("decode payload");
459        assert_eq!(decoded.attestation_type, payload.attestation_type);
460        assert_eq!(decoded.predicate.findings.len(), 1);
461    }
462
463    #[test]
464    fn test_tampered_envelope_fails_verify() {
465        let scan_root = Path::new("/project");
466        let payload = build_attestation(scan_root, &[], &[], &[], None);
467        let mut envelope = DsseEnvelope::new(&payload).expect("create envelope");
468
469        let private_key: [u8; 32] = [42u8; 32];
470        envelope.sign(&private_key).expect("sign envelope");
471
472        // Tamper with the payload
473        let tampered_payload = build_attestation(scan_root, &[], &[], &[], Some("tampered".into()));
474        let tampered_json = serde_json::to_string(&tampered_payload).expect("serialize");
475        envelope.payload = BASE64.encode(tampered_json.as_bytes());
476
477        // Verification should fail
478        let result = envelope.verify();
479        assert!(
480            result.is_err(),
481            "Tampered envelope should fail verification"
482        );
483    }
484
485    #[test]
486    fn test_build_attestation_from_scan() {
487        let fixture = Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject");
488        let opts = crate::ScanOptions::default();
489
490        let report = crate::scan(fixture, &opts).expect("scan fixture");
491
492        let payload = build_attestation(
493            &report.scan_root,
494            &report.findings,
495            &[],
496            &report.targets,
497            None,
498        );
499
500        // Should contain at least one finding (SHIELD-001)
501        assert!(
502            !payload.predicate.findings.is_empty(),
503            "Attestation should include findings from vuln_cmd_inject"
504        );
505
506        // At least one finding should be SHIELD-001
507        assert!(
508            payload
509                .predicate
510                .findings
511                .iter()
512                .any(|f| f.rule_id == "SHIELD-001"),
513            "Expected SHIELD-001 in attestation findings"
514        );
515
516        // Scanner info should be populated
517        assert_eq!(payload.predicate.scanner.name, "agentshield");
518        assert!(payload.predicate.scanner.rule_count > 0);
519    }
520}