1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct DsseEnvelope {
25 #[serde(rename = "payloadType")]
26 pub payload_type: String,
27 pub payload: String,
29 pub signatures: Vec<DsseSignature>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct DsseSignature {
34 pub keyid: String,
35 pub sig: String,
37}
38
39#[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
107fn 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
122impl DsseEnvelope {
127 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 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 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 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
218pub 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 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 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 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 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 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 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#[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 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 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 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 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 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 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 assert!(envelope.verify().expect("verify signed"));
456
457 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 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 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 assert!(
502 !payload.predicate.findings.is_empty(),
503 "Attestation should include findings from vuln_cmd_inject"
504 );
505
506 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 assert_eq!(payload.predicate.scanner.name, "agentshield");
518 assert!(payload.predicate.scanner.rule_count > 0);
519 }
520}