greentic_secrets_validate/
lib.rs1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3
4use greentic_types_validate::{
7 Diagnostic, ExtensionInline, PackManifest, PackValidator, ProviderExtensionInline, Severity,
8};
9use serde_json::Value;
10
11const SECRET_REQUIREMENTS_PATHS: [&str; 2] = [
12 "assets/secret-requirements.json",
13 "assets/secret_requirements.json",
14];
15
16pub fn secrets_validators() -> Vec<Box<dyn PackValidator>> {
18 vec![
19 Box::new(SecretRequirementsDeclValidator),
20 Box::new(SecretRequirementsWellFormedValidator),
21 Box::new(SecretKeyFormatValidator),
22 ]
23}
24
25pub fn is_secrets_pack(manifest: &PackManifest) -> bool {
27 pack_id_starts_with_secrets(manifest)
28 || manifest_references_secret_requirements(manifest)
29 || providers_hint_secrets(manifest)
30}
31
32fn pack_id_starts_with_secrets(manifest: &PackManifest) -> bool {
33 manifest
34 .pack_id
35 .as_str()
36 .to_ascii_lowercase()
37 .starts_with("secrets-")
38}
39
40fn manifest_references_secret_requirements(manifest: &PackManifest) -> bool {
41 if !manifest.secret_requirements.is_empty() {
42 return true;
43 }
44 let Some(extensions) = manifest.extensions.as_ref() else {
45 return false;
46 };
47 for (key, extension) in extensions {
48 if SECRET_REQUIREMENTS_PATHS
49 .iter()
50 .any(|path| key.contains(path) || extension.kind.contains(path))
51 {
52 return true;
53 }
54 if let Some(location) = extension.location.as_ref()
55 && SECRET_REQUIREMENTS_PATHS
56 .iter()
57 .any(|path| location.contains(path))
58 {
59 return true;
60 }
61 if let Some(inline) = extension.inline.as_ref()
62 && inline_mentions_secret_requirements(inline)
63 {
64 return true;
65 }
66 }
67 false
68}
69
70fn inline_mentions_secret_requirements(inline: &ExtensionInline) -> bool {
71 match inline {
72 ExtensionInline::Provider(_) => false,
73 ExtensionInline::Other(value) => value_mentions_secret_requirements(value),
74 }
75}
76
77fn value_mentions_secret_requirements(value: &Value) -> bool {
78 match value {
79 Value::String(text) => SECRET_REQUIREMENTS_PATHS
80 .iter()
81 .any(|path| text.contains(path)),
82 Value::Array(items) => items.iter().any(value_mentions_secret_requirements),
83 Value::Object(map) => map.values().any(value_mentions_secret_requirements),
84 _ => false,
85 }
86}
87
88fn providers_hint_secrets(manifest: &PackManifest) -> bool {
89 manifest
90 .provider_extension_inline()
91 .map(provider_extension_mentions_secrets)
92 .unwrap_or(false)
93}
94
95fn provider_extension_mentions_secrets(inline: &ProviderExtensionInline) -> bool {
96 inline.providers.iter().any(provider_decl_mentions_secrets)
97}
98
99fn provider_decl_mentions_secrets(provider: &greentic_types_validate::ProviderDecl) -> bool {
100 let mut fields = Vec::new();
101 fields.push(provider.provider_type.as_str());
102 fields.push(provider.config_schema_ref.as_str());
103 if let Some(state_schema_ref) = provider.state_schema_ref.as_ref() {
104 fields.push(state_schema_ref.as_str());
105 }
106 if let Some(docs_ref) = provider.docs_ref.as_ref() {
107 fields.push(docs_ref.as_str());
108 }
109 fields.push(provider.runtime.world.as_str());
110 fields.push(provider.runtime.component_ref.as_str());
111
112 fields
113 .into_iter()
114 .any(|value| value.to_ascii_lowercase().contains("secrets"))
115}
116
117fn secrets_required_hint(manifest: &PackManifest) -> bool {
118 is_secrets_pack(manifest)
119 || !manifest.secret_requirements.is_empty()
120 || manifest
121 .capabilities
122 .iter()
123 .any(|cap| cap.name.to_ascii_lowercase().contains("secret"))
124}
125
126struct SecretRequirementsDeclValidator;
127
128impl PackValidator for SecretRequirementsDeclValidator {
129 fn id(&self) -> &'static str {
130 "secrets.requirements.decl"
131 }
132
133 fn applies(&self, manifest: &PackManifest) -> bool {
134 secrets_required_hint(manifest)
135 }
136
137 fn validate(&self, manifest: &PackManifest) -> Vec<Diagnostic> {
138 if manifest_references_secret_requirements(manifest) {
139 return Vec::new();
140 }
141 vec![diagnostic(
142 Severity::Warn,
143 "SEC_REQUIREMENTS_NOT_DISCOVERABLE",
144 "Secrets are required but no secret requirements reference is discoverable.",
145 Some("secret_requirements".to_owned()),
146 Some(
147 "Include assets/secret-requirements.json or embed secret requirements.".to_owned(),
148 ),
149 )]
150 }
151}
152
153struct SecretRequirementsWellFormedValidator;
154
155impl PackValidator for SecretRequirementsWellFormedValidator {
156 fn id(&self) -> &'static str {
157 "secrets.requirements.well_formed"
158 }
159
160 fn applies(&self, manifest: &PackManifest) -> bool {
161 secrets_required_hint(manifest)
162 }
163
164 fn validate(&self, manifest: &PackManifest) -> Vec<Diagnostic> {
165 if manifest.secret_requirements.is_empty() {
166 return vec![diagnostic(
167 Severity::Info,
168 "SEC_REQ_PARSE_NEEDS_PACK_ACCESS",
169 "Secret requirements parse checks require pack file access.",
170 Some("secret_requirements".to_owned()),
171 Some(
172 "Provide secret requirements in the manifest or validate with pack bytes."
173 .to_owned(),
174 ),
175 )];
176 }
177
178 let mut diagnostics = Vec::new();
179 for (idx, req) in manifest.secret_requirements.iter().enumerate() {
180 if req.key.as_str().trim().is_empty() {
181 diagnostics.push(diagnostic(
182 Severity::Error,
183 "SEC_REQ_MISSING_KEY",
184 "Secret requirement is missing a key.",
185 Some(format!("secret_requirements.{idx}.key")),
186 Some("Provide a non-empty key for each secret requirement.".to_owned()),
187 ));
188 }
189 }
190 diagnostics
191 }
192}
193
194struct SecretKeyFormatValidator;
195
196impl PackValidator for SecretKeyFormatValidator {
197 fn id(&self) -> &'static str {
198 "secrets.requirements.key_format"
199 }
200
201 fn applies(&self, manifest: &PackManifest) -> bool {
202 !manifest.secret_requirements.is_empty()
203 }
204
205 fn validate(&self, manifest: &PackManifest) -> Vec<Diagnostic> {
206 let mut diagnostics = Vec::new();
207 for (idx, req) in manifest.secret_requirements.iter().enumerate() {
208 let key = req.key.as_str();
209 if key.trim().is_empty() {
210 continue;
211 }
212 if is_upper_snake(key) || key.starts_with("greentic://") {
213 continue;
214 }
215 diagnostics.push(diagnostic(
216 Severity::Warn,
217 "SEC_BAD_KEY_FORMAT",
218 "Secret requirement key format should be UPPER_SNAKE or greentic:// URI.",
219 Some(format!("secret_requirements.{idx}.key")),
220 Some("Rename the key to UPPER_SNAKE or a greentic:// URI.".to_owned()),
221 ));
222 }
223 diagnostics
224 }
225}
226
227fn is_upper_snake(value: &str) -> bool {
228 !value.is_empty()
229 && value
230 .chars()
231 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
232}
233
234fn diagnostic(
235 severity: Severity,
236 code: &str,
237 message: &str,
238 path: Option<String>,
239 hint: Option<String>,
240) -> Diagnostic {
241 Diagnostic {
242 severity,
243 code: code.to_owned(),
244 message: message.to_owned(),
245 path,
246 hint,
247 data: Value::Null,
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use greentic_types_validate::{
255 ExtensionRef, PROVIDER_EXTENSION_ID, PackId, PackKind, PackSignatures, ProviderDecl,
256 ProviderRuntimeRef,
257 };
258 use semver::Version;
259 use serde::Deserialize;
260 use std::collections::BTreeMap;
261
262 #[derive(Debug, Deserialize)]
263 struct SecretRequirementDecl {
264 #[serde(default)]
265 key: Option<String>,
266 #[serde(default)]
267 name: Option<String>,
268 #[serde(default)]
269 id: Option<String>,
270 #[serde(default)]
271 sensitive: Option<bool>,
272 #[serde(default)]
273 redact: Option<bool>,
274 }
275
276 impl SecretRequirementDecl {
277 fn key_name(&self) -> Option<&str> {
278 self.key
279 .as_deref()
280 .or(self.name.as_deref())
281 .or(self.id.as_deref())
282 }
283
284 fn explicit_sensitivity(&self) -> Option<bool> {
285 self.sensitive.or(self.redact)
286 }
287 }
288
289 fn validate_secret_requirement_decls(
290 decls: &[SecretRequirementDecl],
291 implicit_sensitive: bool,
292 ) -> Vec<Diagnostic> {
293 let mut diagnostics = Vec::new();
294 for (idx, decl) in decls.iter().enumerate() {
295 let key = decl.key_name().unwrap_or_default();
296 if key.trim().is_empty() {
297 diagnostics.push(diagnostic(
298 Severity::Error,
299 "SEC_REQ_MISSING_KEY",
300 "Secret requirement is missing a key.",
301 Some(format!("secret_requirements.{idx}.key")),
302 Some("Provide a non-empty key/name for each requirement.".to_owned()),
303 ));
304 continue;
305 }
306 match decl.explicit_sensitivity() {
307 Some(false) => diagnostics.push(diagnostic(
308 Severity::Error,
309 "SEC_REQ_EXPLICITLY_NOT_SENSITIVE",
310 "Secret requirement explicitly marks non-sensitive data.",
311 Some(format!("secret_requirements.{idx}.sensitive")),
312 Some("Remove the explicit false or mark secrets as sensitive.".to_owned()),
313 )),
314 Some(true) => {}
315 None if !implicit_sensitive => diagnostics.push(diagnostic(
316 Severity::Error,
317 "SEC_REQ_NOT_SENSITIVE",
318 "Secret requirement is not marked sensitive.",
319 Some(format!("secret_requirements.{idx}.sensitive")),
320 Some("Mark secrets as sensitive or use a secrets-only structure.".to_owned()),
321 )),
322 None => {}
323 }
324 }
325 diagnostics
326 }
327
328 fn base_manifest(pack_id: &str) -> PackManifest {
329 PackManifest {
330 schema_version: "pack-v1".to_owned(),
331 pack_id: pack_id.parse::<PackId>().expect("pack id"),
332 name: None,
333 version: Version::parse("0.1.0").expect("version"),
334 kind: PackKind::Application,
335 publisher: "greentic".to_owned(),
336 components: Vec::new(),
337 flows: Vec::new(),
338 dependencies: Vec::new(),
339 capabilities: Vec::new(),
340 secret_requirements: Vec::new(),
341 signatures: PackSignatures::default(),
342 bootstrap: None,
343 extensions: None,
344 }
345 }
346
347 #[test]
348 fn detects_secrets_pack_by_id_prefix() {
349 let manifest = base_manifest("secrets-demo");
350 assert!(is_secrets_pack(&manifest));
351 }
352
353 #[test]
354 fn warns_when_secrets_requirements_missing() {
355 let mut manifest = base_manifest("vendor.demo.pack");
356 let provider = ProviderDecl {
357 provider_type: "demo".to_owned(),
358 capabilities: Vec::new(),
359 ops: Vec::new(),
360 config_schema_ref: "assets/schemas/secrets/demo/config.schema.json".to_owned(),
361 state_schema_ref: None,
362 runtime: ProviderRuntimeRef {
363 component_ref: "component".to_owned(),
364 export: "invoke".to_owned(),
365 world: "greentic:provider-schema-core/schema-core@1.0.0".to_owned(),
366 },
367 docs_ref: None,
368 };
369 let inline = ProviderExtensionInline {
370 providers: vec![provider],
371 additional_fields: BTreeMap::new(),
372 };
373 let mut extensions = BTreeMap::new();
374 extensions.insert(
375 PROVIDER_EXTENSION_ID.to_owned(),
376 ExtensionRef {
377 kind: PROVIDER_EXTENSION_ID.to_owned(),
378 version: "1.0.0".to_owned(),
379 digest: None,
380 location: None,
381 inline: Some(ExtensionInline::Provider(inline)),
382 },
383 );
384 manifest.extensions = Some(extensions);
385
386 let validator = SecretRequirementsDeclValidator;
387 let diagnostics = validator.validate(&manifest);
388 assert!(
389 diagnostics
390 .iter()
391 .any(|diag| diag.code == "SEC_REQUIREMENTS_NOT_DISCOVERABLE")
392 );
393 }
394
395 #[test]
396 fn detects_missing_key() {
397 let raw = r#"[{"sensitive": true}]"#;
398 let decls: Vec<SecretRequirementDecl> = serde_json::from_str(raw).expect("parse");
399 let diagnostics = validate_secret_requirement_decls(&decls, true);
400 assert!(
401 diagnostics
402 .iter()
403 .any(|diag| diag.code == "SEC_REQ_MISSING_KEY")
404 );
405 }
406
407 #[test]
408 fn detects_explicit_not_sensitive() {
409 let raw = r#"[{"key": "API_KEY", "sensitive": false}]"#;
410 let decls: Vec<SecretRequirementDecl> = serde_json::from_str(raw).expect("parse");
411 let diagnostics = validate_secret_requirement_decls(&decls, true);
412 assert!(
413 diagnostics
414 .iter()
415 .any(|diag| diag.code == "SEC_REQ_EXPLICITLY_NOT_SENSITIVE")
416 );
417 }
418
419 #[test]
420 fn accepts_valid_sensitive_requirement() {
421 let raw = r#"[{"key": "API_KEY", "sensitive": true}]"#;
422 let decls: Vec<SecretRequirementDecl> = serde_json::from_str(raw).expect("parse");
423 let diagnostics = validate_secret_requirement_decls(&decls, true);
424 assert!(diagnostics.is_empty());
425 }
426
427 #[test]
428 fn detects_secret_requirements_from_extension_location() {
429 let mut manifest = base_manifest("vendor.demo.pack");
430 let mut extensions = BTreeMap::new();
431 extensions.insert(
432 "vendor.secret-req".to_owned(),
433 ExtensionRef {
434 kind: "vendor.secret-req".to_owned(),
435 version: "1.0.0".to_owned(),
436 digest: None,
437 location: Some("assets/secret-requirements.json".to_owned()),
438 inline: None,
439 },
440 );
441 manifest.extensions = Some(extensions);
442
443 assert!(manifest_references_secret_requirements(&manifest));
444 assert!(is_secrets_pack(&manifest));
445 }
446
447 #[test]
448 fn detects_secret_requirements_from_unknown_inline_payload() {
449 let mut manifest = base_manifest("vendor.demo.pack");
450 let mut extensions = BTreeMap::new();
451 extensions.insert(
452 "vendor.secret-req".to_owned(),
453 ExtensionRef {
454 kind: "vendor.secret-req".to_owned(),
455 version: "1.0.0".to_owned(),
456 digest: None,
457 location: None,
458 inline: Some(ExtensionInline::Other(serde_json::json!({
459 "path": "assets/secret_requirements.json"
460 }))),
461 },
462 );
463 manifest.extensions = Some(extensions);
464
465 assert!(manifest_references_secret_requirements(&manifest));
466 }
467
468 #[test]
469 fn key_format_validator_accepts_greentic_uri_keys() {
470 let mut manifest = base_manifest("vendor.demo.pack");
471 let mut requirement = greentic_types_validate::SecretRequirement::default();
472 requirement.key = "greentic://tenant/configs/db".into();
473 manifest.secret_requirements.push(requirement);
474
475 let diagnostics = SecretKeyFormatValidator.validate(&manifest);
476 assert!(diagnostics.is_empty());
477 }
478
479 #[test]
480 fn validators_are_registered_in_expected_order() {
481 let ids: Vec<_> = secrets_validators()
482 .into_iter()
483 .map(|validator| validator.id())
484 .collect();
485 assert_eq!(
486 ids,
487 vec![
488 "secrets.requirements.decl",
489 "secrets.requirements.well_formed",
490 "secrets.requirements.key_format"
491 ]
492 );
493 }
494
495 #[test]
496 fn well_formed_validator_reports_pack_access_requirement_without_embedded_requirements() {
497 let manifest = base_manifest("vendor.demo.pack");
498 let diagnostics = SecretRequirementsWellFormedValidator.validate(&manifest);
499 assert!(
500 diagnostics
501 .iter()
502 .any(|diag| diag.code == "SEC_REQ_PARSE_NEEDS_PACK_ACCESS")
503 );
504 }
505
506 #[test]
507 fn secrets_required_hint_detects_secret_capability_names() {
508 let mut manifest = base_manifest("vendor.demo.pack");
509 manifest
510 .capabilities
511 .push(greentic_types_validate::ComponentCapability {
512 name: "secret-sync".into(),
513 description: None,
514 });
515
516 assert!(secrets_required_hint(&manifest));
517 }
518}