Skip to main content

greentic_secrets_pack_validator/
lib.rs

1#![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}