greentic_secrets_pack_validator/
lib.rs1#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
2
3use greentic_types::{PackManifest, ProviderDecl, ProviderExtensionInline, decode_pack_manifest};
4
5wit_bindgen::generate!({
6 world: "pack-validator",
7 path: "wit/greentic/pack-validate@0.1.0",
8});
9
10use exports::greentic::pack_validate::validator::{Diagnostic, Guest, PackInputs};
11
12const SECRET_REQUIREMENTS_ASSET: &str = "assets/secret-requirements.json";
13const SECRET_REQUIREMENTS_ASSET_ALT: &str = "assets/secret_requirements.json";
14
15struct SecretsPackValidator;
16
17impl Guest for SecretsPackValidator {
18 fn applies(inputs: PackInputs) -> bool {
19 let file_index = inputs.file_index;
20 let asset_present = has_secret_requirements_asset(&file_index);
21 if let Some(manifest) = decode_manifest(&inputs.manifest_cbor) {
22 secrets_required(&manifest) || asset_present
23 } else {
24 asset_present
25 }
26 }
27
28 fn validate(inputs: PackInputs) -> Vec<Diagnostic> {
29 let mut diagnostics = Vec::new();
30 let file_index = inputs.file_index;
31 let asset_present = has_secret_requirements_asset(&file_index);
32 let manifest = decode_manifest(&inputs.manifest_cbor);
33 let secrets_required = manifest
34 .as_ref()
35 .map(secrets_required)
36 .unwrap_or(asset_present);
37
38 if !secrets_required {
39 return diagnostics;
40 }
41
42 if !asset_present {
43 diagnostics.push(diagnostic(
44 "error",
45 "SEC_REQUIREMENTS_ASSET_MISSING",
46 "Secret requirements asset is missing from the pack.",
47 Some(SECRET_REQUIREMENTS_ASSET.to_owned()),
48 Some("Add assets/secret-requirements.json to the pack.".to_owned()),
49 ));
50 }
51
52 if !can_check_sensitivity() {
53 diagnostics.push(diagnostic(
54 "warn",
55 "SEC_SECRET_NOT_SENSITIVE",
56 "Secret requirements sensitivity checks require asset bytes.",
57 Some(SECRET_REQUIREMENTS_ASSET.to_owned()),
58 Some(
59 "Provide secret-requirements.json bytes to enable sensitivity checks."
60 .to_owned(),
61 ),
62 ));
63 }
64
65 if let Some(manifest) = manifest.as_ref() {
66 diagnostics.extend(validate_key_format(manifest));
67 }
68
69 diagnostics
70 }
71}
72
73#[cfg(target_arch = "wasm32")]
74export!(SecretsPackValidator);
75
76fn decode_manifest(bytes: &[u8]) -> Option<PackManifest> {
77 decode_pack_manifest(bytes).ok()
78}
79
80fn has_secret_requirements_asset(file_index: &[String]) -> bool {
81 file_index
82 .iter()
83 .any(|entry| entry == SECRET_REQUIREMENTS_ASSET || entry == SECRET_REQUIREMENTS_ASSET_ALT)
84}
85
86fn secrets_required(manifest: &PackManifest) -> bool {
87 let pack_id = manifest.pack_id.as_str().to_ascii_lowercase();
88 if pack_id.starts_with("secrets-") || pack_id.contains(".secrets.") {
89 return true;
90 }
91 if !manifest.secret_requirements.is_empty() {
92 return true;
93 }
94 manifest
95 .provider_extension_inline()
96 .map(provider_extension_mentions_secrets)
97 .unwrap_or(false)
98}
99
100fn provider_extension_mentions_secrets(inline: &ProviderExtensionInline) -> bool {
101 inline.providers.iter().any(provider_decl_mentions_secrets)
102}
103
104fn provider_decl_mentions_secrets(provider: &ProviderDecl) -> bool {
105 let mut fields = Vec::new();
106 fields.push(provider.provider_type.as_str());
107 fields.push(provider.config_schema_ref.as_str());
108 if let Some(state_schema_ref) = provider.state_schema_ref.as_ref() {
109 fields.push(state_schema_ref.as_str());
110 }
111 if let Some(docs_ref) = provider.docs_ref.as_ref() {
112 fields.push(docs_ref.as_str());
113 }
114 fields.push(provider.runtime.world.as_str());
115 fields.push(provider.runtime.component_ref.as_str());
116
117 fields
118 .into_iter()
119 .any(|value| value.to_ascii_lowercase().contains("secrets"))
120}
121
122fn validate_key_format(manifest: &PackManifest) -> Vec<Diagnostic> {
123 let mut diagnostics = Vec::new();
124 for (idx, req) in manifest.secret_requirements.iter().enumerate() {
125 let key = req.key.as_str();
126 if key.is_empty() {
127 continue;
128 }
129 if is_upper_snake(key) || key.starts_with("greentic://") {
130 continue;
131 }
132 diagnostics.push(diagnostic(
133 "warn",
134 "SEC_BAD_KEY_FORMAT",
135 "Secret requirement key format should be UPPER_SNAKE or greentic:// URI.",
136 Some(format!("secret_requirements.{idx}.key")),
137 Some("Rename the key to UPPER_SNAKE or a greentic:// URI.".to_owned()),
138 ));
139 }
140 diagnostics
141}
142
143fn is_upper_snake(value: &str) -> bool {
144 !value.is_empty()
145 && value
146 .chars()
147 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
148}
149
150fn can_check_sensitivity() -> bool {
151 false
152}
153
154fn diagnostic(
155 severity: &str,
156 code: &str,
157 message: &str,
158 path: Option<String>,
159 hint: Option<String>,
160) -> Diagnostic {
161 Diagnostic {
162 severity: severity.to_owned(),
163 code: code.to_owned(),
164 message: message.to_owned(),
165 path,
166 hint,
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use greentic_types::{
174 ExtensionInline, ExtensionRef, PROVIDER_EXTENSION_ID, PackId, PackKind, PackSignatures,
175 ProviderRuntimeRef, SecretRequirement, encode_pack_manifest,
176 };
177 use semver::Version;
178 use std::collections::BTreeMap;
179
180 fn manifest_with_pack_id(pack_id: &str) -> PackManifest {
181 PackManifest {
182 schema_version: "pack-v1".into(),
183 pack_id: PackId::new(pack_id).expect("pack id"),
184 name: None,
185 version: Version::parse("0.1.0").expect("version"),
186 kind: PackKind::Application,
187 publisher: "greentic".into(),
188 components: Vec::new(),
189 flows: Vec::new(),
190 dependencies: Vec::new(),
191 capabilities: Vec::new(),
192 secret_requirements: Vec::new(),
193 signatures: PackSignatures::default(),
194 bootstrap: None,
195 extensions: None,
196 }
197 }
198
199 fn provider_inline(component_ref: &str) -> ProviderExtensionInline {
200 ProviderExtensionInline {
201 providers: vec![ProviderDecl {
202 provider_type: "vendor.provider".into(),
203 capabilities: Vec::new(),
204 ops: Vec::new(),
205 config_schema_ref: "assets/schemas/secrets/demo/config.schema.json".into(),
206 state_schema_ref: None,
207 runtime: ProviderRuntimeRef {
208 component_ref: component_ref.into(),
209 export: "invoke".into(),
210 world: "greentic:provider/schema-core@1.0.0".into(),
211 },
212 docs_ref: None,
213 }],
214 additional_fields: BTreeMap::new(),
215 }
216 }
217
218 #[test]
219 fn detects_secret_requirements_assets() {
220 assert!(has_secret_requirements_asset(&[
221 "assets/secret-requirements.json".to_string()
222 ]));
223 assert!(has_secret_requirements_asset(&[
224 "assets/secret_requirements.json".to_string()
225 ]));
226 assert!(!has_secret_requirements_asset(&["README.md".to_string()]));
227 }
228
229 #[test]
230 fn secrets_required_for_pack_id_requirements_and_provider_hints() {
231 let by_pack_id = manifest_with_pack_id("secrets-demo");
232 assert!(secrets_required(&by_pack_id));
233
234 let mut by_requirement = manifest_with_pack_id("vendor.demo");
235 let mut requirement = SecretRequirement::default();
236 requirement.key = "API_KEY".into();
237 by_requirement.secret_requirements.push(requirement);
238 assert!(secrets_required(&by_requirement));
239
240 let mut by_provider = manifest_with_pack_id("vendor.demo");
241 let mut extensions = BTreeMap::new();
242 extensions.insert(
243 PROVIDER_EXTENSION_ID.to_string(),
244 ExtensionRef {
245 kind: PROVIDER_EXTENSION_ID.into(),
246 version: "1.0.0".into(),
247 digest: None,
248 location: None,
249 inline: Some(ExtensionInline::Provider(provider_inline(
250 "vendor.secrets.runtime",
251 ))),
252 },
253 );
254 by_provider.extensions = Some(extensions);
255 assert!(secrets_required(&by_provider));
256 }
257
258 #[test]
259 fn validate_key_format_warns_for_non_secret_style_keys() {
260 let mut manifest = manifest_with_pack_id("vendor.demo");
261 let mut bad = SecretRequirement::default();
262 bad.key = "dbPassword".into();
263 manifest.secret_requirements.push(bad);
264
265 let diagnostics = validate_key_format(&manifest);
266 assert_eq!(diagnostics.len(), 1);
267 assert_eq!(diagnostics[0].code, "SEC_BAD_KEY_FORMAT");
268 }
269
270 #[test]
271 fn validate_key_format_accepts_upper_snake_and_uri_keys() {
272 let mut manifest = manifest_with_pack_id("vendor.demo");
273 let mut env_key = SecretRequirement::default();
274 env_key.key = "DB_PASSWORD".into();
275 let mut uri_key = SecretRequirement::default();
276 uri_key.key = "greentic://tenant/configs/db".into();
277 manifest.secret_requirements.extend([env_key, uri_key]);
278
279 assert!(validate_key_format(&manifest).is_empty());
280 }
281
282 #[test]
283 fn decode_manifest_roundtrips_encoded_bytes() {
284 let manifest = manifest_with_pack_id("vendor.demo");
285 let bytes = encode_pack_manifest(&manifest).expect("encode");
286 let decoded = decode_manifest(&bytes).expect("decode");
287 assert_eq!(decoded.pack_id, manifest.pack_id);
288 }
289
290 #[test]
291 fn is_upper_snake_requires_only_upper_ascii_digits_or_underscores() {
292 assert!(is_upper_snake("DB_PASSWORD_2"));
293 assert!(!is_upper_snake("db_password"));
294 assert!(!is_upper_snake("DB-PASSWORD"));
295 assert!(!is_upper_snake(""));
296 }
297
298 #[test]
299 fn provider_decl_only_matches_secretish_fields() {
300 let non_secret = ProviderDecl {
301 provider_type: "vendor.cache".into(),
302 capabilities: Vec::new(),
303 ops: Vec::new(),
304 config_schema_ref: "assets/schemas/cache/config.schema.json".into(),
305 state_schema_ref: None,
306 runtime: ProviderRuntimeRef {
307 component_ref: "vendor.cache.runtime".into(),
308 export: "invoke".into(),
309 world: "greentic:provider/schema-core@1.0.0".into(),
310 },
311 docs_ref: None,
312 };
313 assert!(!provider_decl_mentions_secrets(&non_secret));
314 assert!(!provider_extension_mentions_secrets(
315 &ProviderExtensionInline {
316 providers: vec![non_secret],
317 additional_fields: BTreeMap::new(),
318 }
319 ));
320 }
321
322 #[test]
323 fn guest_applies_and_validate_cover_asset_and_missing_asset_paths() {
324 let manifest = manifest_with_pack_id("secrets-demo");
325 let manifest_cbor = encode_pack_manifest(&manifest).expect("encode");
326
327 assert!(<SecretsPackValidator as Guest>::applies(PackInputs {
328 manifest_cbor: manifest_cbor.clone(),
329 sbom_json: "{}".into(),
330 file_index: vec![SECRET_REQUIREMENTS_ASSET.to_owned()],
331 }));
332
333 let diagnostics = <SecretsPackValidator as Guest>::validate(PackInputs {
334 manifest_cbor,
335 sbom_json: "{}".into(),
336 file_index: Vec::new(),
337 });
338 assert!(
339 diagnostics
340 .iter()
341 .any(|diag| diag.code == "SEC_REQUIREMENTS_ASSET_MISSING")
342 );
343 assert!(
344 diagnostics
345 .iter()
346 .any(|diag| diag.code == "SEC_SECRET_NOT_SENSITIVE")
347 );
348 }
349
350 #[test]
351 fn guest_uses_asset_presence_when_manifest_cannot_be_decoded() {
352 assert!(<SecretsPackValidator as Guest>::applies(PackInputs {
353 manifest_cbor: vec![1, 2, 3],
354 sbom_json: "{}".into(),
355 file_index: vec![SECRET_REQUIREMENTS_ASSET_ALT.to_owned()],
356 }));
357 }
358}