1use std::collections::{BTreeMap, BTreeSet};
22
23use super::layout::{RelPath, ResourceKey};
24use super::node::Node;
25use super::rules::{openapi_describes, secret_paths};
26use super::target::{Target, TargetOs};
27use super::tree::RenderedTree;
28use crate::error::DeployFinding;
29
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct SecretScan {
33 pub refused: Vec<String>,
36 pub warnings: Vec<String>,
38}
39
40const ROOT_PATH: &str = "$";
42
43fn is_embedded(node: &Node) -> bool {
46 matches!(node.get("type"), Some(Node::Str(t)) if t == "Embedded")
47 && matches!(node.get("data"), Some(Node::Obj(_)))
48}
49
50fn is_referenced(node: &Node) -> bool {
58 let Node::Obj(members) = node else {
59 return false;
60 };
61 if members.len() != 2 || !matches!(node.get("type"), Some(Node::Str(t)) if t == "Referenced") {
62 return false;
63 }
64 match node.get("data") {
65 Some(data @ Node::Obj(inner)) => {
66 inner.len() == 2
67 && matches!(data.get("providerName"), Some(Node::Str(_)))
68 && matches!(data.get("secretName"), Some(Node::Str(_)))
69 }
70 _ => false,
71 }
72}
73
74pub fn referenced_secret_names(node: &Node, out: &mut BTreeSet<(String, String)>) {
83 if is_referenced(node) {
84 if let Some(data) = node.get("data")
85 && let (Some(Node::Str(provider)), Some(Node::Str(name))) =
86 (data.get("providerName"), data.get("secretName"))
87 {
88 out.insert((provider.clone(), name.clone()));
89 }
90 return;
91 }
92 match node {
93 Node::Obj(members) => {
94 for (_, member) in members {
95 referenced_secret_names(member, out);
96 }
97 }
98 Node::Arr(items) => {
99 for item in items {
100 referenced_secret_names(item, out);
101 }
102 }
103 Node::Str(_) | Node::Lit(_) => {}
104 }
105}
106
107fn key_looks_secret(key: &str) -> bool {
110 let lower = key.to_ascii_lowercase();
111 lower.contains("password") || lower.contains("secret")
112}
113
114fn member_path(parent: &str, key: &str) -> String {
116 if parent.is_empty() {
117 key.to_string()
118 } else {
119 format!("{parent}.{key}")
120 }
121}
122
123struct Rules<'a> {
125 schema: &'a [&'static str],
127 key_names: bool,
129 described: bool,
131}
132
133struct Walker<'a> {
134 origin: &'a str,
135 rules: Option<Rules<'a>>,
136 scan: SecretScan,
137}
138
139impl Walker<'_> {
140 fn refuse(&mut self, path: &str, kind: &str) {
141 let shown = if path.is_empty() { ROOT_PATH } else { path };
142 self.scan
143 .refused
144 .push(format!("{}: {shown} ({kind})", self.origin));
145 }
146
147 fn walk(&mut self, node: &Node, path: &str, key_hit: bool) {
152 match node {
153 Node::Obj(members) => {
154 if is_embedded(node) {
155 self.refuse(path, "embedded");
156 return;
157 }
158 if is_referenced(node) {
159 return;
161 }
162 for (key, value) in members {
163 let child = member_path(path, key);
164 let at_schema = self
165 .rules
166 .as_ref()
167 .is_some_and(|r| r.schema.contains(&child.as_str()));
168 if at_schema {
169 self.schema_value(value, &child);
170 continue;
171 }
172 let hit =
173 self.rules.as_ref().is_some_and(|r| r.key_names) && key_looks_secret(key);
174 self.walk(value, &child, hit);
175 }
176 }
177 Node::Arr(items) => {
178 for (i, item) in items.iter().enumerate() {
179 self.walk(item, &format!("{path}[{i}]"), key_hit);
180 }
181 }
182 Node::Str(s) => {
183 if key_hit && !s.is_empty() {
184 self.key_name_hit(path);
185 }
186 }
187 Node::Lit(_) => {}
188 }
189 }
190
191 fn schema_value(&mut self, value: &Node, path: &str) {
195 match value {
196 Node::Lit(l) if l == "null" => {}
197 Node::Obj(_) if is_embedded(value) => self.refuse(path, "embedded"),
198 Node::Obj(_) if is_referenced(value) => {}
199 _ => self.refuse(path, "plaintext"),
200 }
201 }
202
203 fn key_name_hit(&mut self, path: &str) {
205 let described = self.rules.as_ref().is_some_and(|r| r.described);
206 if described {
207 self.scan.warnings.push(format!(
208 "possible plaintext secret at {}: {path}; this field is not secret-typed in the 8.3.6 OpenAPI, check it is not a secret",
209 self.origin
210 ));
211 } else {
212 self.refuse(path, "plaintext, by key name");
213 }
214 }
215}
216
217pub fn scan_embedded(origin: &str, node: &Node) -> SecretScan {
221 let mut walker = Walker {
222 origin,
223 rules: None,
224 scan: SecretScan::default(),
225 };
226 walker.walk(node, "", false);
227 walker.scan
228}
229
230pub fn scan_secrets(origin: &str, key: &ResourceKey, file_name: &str, node: &Node) -> SecretScan {
246 if file_name != "config.json" {
247 return scan_embedded(origin, node);
248 }
249 let mut walker = Walker {
250 origin,
251 rules: Some(Rules {
252 schema: secret_paths(&key.module, &key.ty),
253 key_names: true,
254 described: openapi_describes(&key.module, &key.ty),
255 }),
256 scan: SecretScan::default(),
257 };
258 walker.walk(node, "", false);
259 walker.scan
260}
261
262pub const TAG_CREDENTIAL_MARKERS: [&str; 7] = [
268 "password",
269 "passwd",
270 "secret",
271 "token",
272 "apikey",
273 "api_key",
274 "credential",
275];
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub enum Sends {
281 All,
284 Config,
289 Nothing,
291}
292
293fn tag_name_is_credential(name: &str) -> bool {
295 let lower = name.to_ascii_lowercase();
296 TAG_CREDENTIAL_MARKERS
297 .iter()
298 .any(|marker| lower.contains(marker))
299}
300
301fn holds_text(node: &Node) -> bool {
304 match node {
305 Node::Str(s) => !s.is_empty(),
306 Node::Obj(members) => members.iter().any(|(_, v)| holds_text(v)),
307 Node::Arr(items) => items.iter().any(holds_text),
308 Node::Lit(_) => false,
309 }
310}
311
312pub fn scan_tag_node(origin: &str, path: &str, node: &Node, sends: Sends) -> SecretScan {
330 let mut out = SecretScan::default();
331 for location in scan_embedded(origin, node).refused {
332 if sends == Sends::Nothing {
333 out.warnings
334 .push(format!("{location}: nothing is sent for this tag"));
335 } else {
336 out.refused.push(location);
337 }
338 }
339 let name = path.rsplit('/').next().unwrap_or(path);
340 let types = super::tag_canon::in_types(path);
341 credential_walk(origin, node, name, "", sends, types, &mut out);
342 out
343}
344
345fn credential_walk(
348 origin: &str,
349 node: &Node,
350 name: &str,
351 at: &str,
352 sends: Sends,
353 types: bool,
354 out: &mut SecretScan,
355) {
356 if tag_name_is_credential(name)
357 && let Node::Obj(members) = node
358 {
359 for (key, value) in members {
360 if !matches!(key.as_str(), "value" | "defaultValue")
361 || is_embedded(value)
362 || !holds_text(value)
363 {
364 continue;
365 }
366 let location = format!("{origin}: {} (credential value)", member_path(at, key));
367 let sent = match sends {
368 Sends::All => true,
369 Sends::Config => key == "defaultValue" || types,
370 Sends::Nothing => false,
371 };
372 if sent {
373 out.refused.push(location);
374 } else if sends == Sends::Config {
375 out.warnings
376 .push(format!("{location}: not sent, the live value is kept"));
377 } else {
378 out.warnings
379 .push(format!("{location}: nothing is sent for this tag"));
380 }
381 }
382 }
383 if let Some(Node::Arr(items)) = node.get("tags") {
384 for item in items {
385 let Some(Node::Str(child)) = item.get("name") else {
386 continue;
387 };
388 let below = member_path(&member_path(at, "tags"), child);
389 credential_walk(origin, item, child, &below, sends, types, out);
390 }
391 }
392}
393
394pub const WINDOWS_MAX_PATH: usize = 248;
401
402pub const WINDOWS_MAX_COMPONENT: usize = 255;
404
405pub const DEFAULT_WINDOWS_DATA_DIR: &str = r"C:\Program Files\Inductive Automation\Ignition\data";
408
409pub const COLLECTION_SEGMENT: &str = r"\config\resources\";
412
413pub const RESERVED_STEMS: [&str; 22] = [
416 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
417 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
418];
419
420pub const TEXT_EXTENSIONS: [&str; 13] = [
423 "json",
424 "css",
425 "yaml",
426 "yml",
427 "txt",
428 "md",
429 "xml",
430 "py",
431 "csv",
432 "properties",
433 "html",
434 "js",
435 "svg",
436];
437
438fn utf16_len(s: &str) -> usize {
440 s.encode_utf16().count()
441}
442
443pub fn windows_prefix_len(target: &Target) -> usize {
449 let nodes = std::iter::once(&target.nodes.master).chain(target.nodes.backup.as_ref());
450 let data_dir = nodes
451 .filter_map(|n| n.ssh.as_ref())
452 .filter(|ssh| ssh.os == TargetOs::Windows)
453 .map(|ssh| utf16_len(ssh.data_dir.trim_end_matches(['\\', '/'])))
454 .fold(utf16_len(DEFAULT_WINDOWS_DATA_DIR), usize::max);
455 data_dir + utf16_len(COLLECTION_SEGMENT) + utf16_len(&target.mode)
456}
457
458pub fn is_text_member(path: &str, bytes: &[u8]) -> bool {
462 let ext = path.rsplit_once('.').map(|(_, e)| e);
463 if let Some(ext) = ext
464 && !ext.contains('/')
465 && TEXT_EXTENSIONS.iter().any(|t| t.eq_ignore_ascii_case(ext))
466 {
467 return true;
468 }
469 !bytes.contains(&0) && std::str::from_utf8(bytes).is_ok()
470}
471
472fn windows_problems(name: &str) -> Vec<String> {
477 let mut problems = Vec::new();
478 if let Some(c) = name
479 .chars()
480 .find(|c| matches!(c, '<' | '>' | ':' | '"' | '|' | '?' | '*') || c.is_control())
481 {
482 let shown = if c.is_control() {
483 format!("the control character U+{:04X}", c as u32)
484 } else {
485 format!("`{c}`")
486 };
487 problems.push(format!(
488 "contains {shown}, which Windows does not allow in a name"
489 ));
490 }
491 if name.ends_with('.') || name.ends_with(' ') {
492 problems.push("ends in a `.` or a space, which Windows strips".to_string());
493 }
494 let units = utf16_len(name);
495 if units > WINDOWS_MAX_COMPONENT {
496 problems.push(format!(
497 "is {units} UTF-16 code units long; a Windows name component may be at most {WINDOWS_MAX_COMPONENT}"
498 ));
499 }
500 let stem = name.split('.').next().unwrap_or(name).to_ascii_uppercase();
501 if RESERVED_STEMS.contains(&stem.as_str()) {
502 problems.push(format!(
503 "is the reserved Windows device name `{stem}` (reserved with any extension)"
504 ));
505 }
506 problems
507}
508
509fn first_cr(bytes: &[u8]) -> Option<(usize, usize)> {
512 let at = bytes.iter().position(|&b| b == b'\r')?;
513 let before = &bytes[..at];
514 let line = before.iter().filter(|&&b| b == b'\n').count() + 1;
515 let line_start = before
516 .iter()
517 .rposition(|&b| b == b'\n')
518 .map_or(0, |i| i + 1);
519 Some((line, at - line_start + 1))
520}
521
522pub fn lint_tree(
524 tree: &RenderedTree,
525 target: &Target,
526 env: &str,
527 label: &dyn Fn(&RelPath) -> String,
528) -> Vec<DeployFinding> {
529 lint_files(&tree.files, target, env, label)
530}
531
532pub fn lint_files(
552 files: &BTreeMap<RelPath, Vec<u8>>,
553 target: &Target,
554 env: &str,
555 label: &dyn Fn(&RelPath) -> String,
556) -> Vec<DeployFinding> {
557 let mut findings = Vec::new();
558 let finding = |file: String, line: Option<(usize, usize)>, message: String| DeployFinding {
559 file,
560 line: line.map(|l| l.0),
561 column: line.map(|l| l.1),
562 message,
563 };
564
565 for problem in windows_problems(&target.mode) {
566 findings.push(finding(
567 format!("targets/{env}.yaml"),
568 None,
569 format!("mode `{}` {problem}", target.mode),
570 ));
571 }
572
573 let prefix = windows_prefix_len(target);
574 let mut seen: BTreeSet<&str> = BTreeSet::new();
576 let mut siblings: BTreeMap<&str, BTreeMap<String, BTreeMap<&str, String>>> = BTreeMap::new();
578
579 for (path, bytes) in files {
580 let full = path.as_str();
581 let source = label(path);
582
583 let mut end = 0;
584 for name in path.components() {
585 let start = end;
586 end += name.len();
587 let upto = &full[..end];
588 if end < full.len() {
589 end += 1; }
591 if !seen.insert(upto) {
592 continue;
593 }
594 let parent = if start == 0 { "" } else { &full[..start - 1] };
595 siblings
596 .entry(parent)
597 .or_default()
598 .entry(name.to_lowercase())
599 .or_default()
600 .entry(name)
601 .or_insert_with(|| source.clone());
602 for problem in windows_problems(name) {
603 findings.push(finding(
604 source.clone(),
605 None,
606 format!("path component `{name}` {problem}"),
607 ));
608 }
609 }
610
611 let total = prefix + 1 + utf16_len(full);
612 if total >= WINDOWS_MAX_PATH {
613 findings.push(finding(
614 source.clone(),
615 None,
616 format!(
617 "the path would be {total} UTF-16 code units long on Windows (data directory, `{}`, the mode and `{full}`); it must be shorter than {WINDOWS_MAX_PATH}",
618 COLLECTION_SEGMENT.trim_matches('\\')
619 ),
620 ));
621 }
622
623 if is_text_member(full, bytes)
624 && let Some((line, column)) = first_cr(bytes)
625 {
626 findings.push(finding(
627 source,
628 Some((line, column)),
629 format!(
630 "carriage return (0x0D) at line {line} of a text file; render refuses CR so no tool can convert line endings (use LF)"
631 ),
632 ));
633 }
634 }
635
636 for (parent, groups) in &siblings {
637 for names in groups.values().filter(|names| names.len() > 1) {
638 let shown: Vec<String> = names
639 .keys()
640 .map(|n| {
641 if parent.is_empty() {
642 format!("`{n}`")
643 } else {
644 format!("`{parent}/{n}`")
645 }
646 })
647 .collect();
648 let first = names.values().next().cloned().unwrap_or_default();
649 findings.push(finding(
650 first,
651 None,
652 format!(
653 "sibling names collide when case is folded ({}); a case-insensitive filesystem keeps only one",
654 shown.join(", ")
655 ),
656 ));
657 }
658 }
659 findings
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665 use crate::deploy::node;
666
667 fn collect(json: &str) -> Vec<(String, String)> {
668 let mut out = BTreeSet::new();
669 referenced_secret_names(&node::parse(json).unwrap(), &mut out);
670 out.into_iter().collect()
671 }
672
673 fn pair(provider: &str, name: &str) -> (String, String) {
674 (provider.to_string(), name.to_string())
675 }
676
677 const REF_A: &str = r#"{"type":"Referenced","data":{"providerName":"p","secretName":"a"}}"#;
678
679 #[test]
680 fn collects_every_referenced_pair() {
681 let doc = format!(
682 r#"{{"top":{REF_A},"nested":{{"deep":{{"type":"Referenced","data":{{"providerName":"p","secretName":"b"}}}}}},"list":[1,{{"type":"Referenced","data":{{"providerName":"q","secretName":"c"}}}}]}}"#
683 );
684 assert_eq!(
685 collect(&doc),
686 [pair("p", "a"), pair("p", "b"), pair("q", "c")]
687 );
688 assert_eq!(collect(REF_A), [pair("p", "a")]);
690 }
691
692 #[test]
693 fn duplicate_reference_collects_once() {
694 let doc = format!(r#"{{"x":{REF_A},"y":[{REF_A}]}}"#);
695 assert_eq!(collect(&doc), [pair("p", "a")]);
696 }
697
698 #[test]
699 fn same_name_two_providers_collects_twice() {
700 let doc = r#"[{"type":"Referenced","data":{"providerName":"p2","secretName":"a"}},{"type":"Referenced","data":{"providerName":"p1","secretName":"a"}}]"#;
701 assert_eq!(collect(doc), [pair("p1", "a"), pair("p2", "a")]);
703 }
704
705 #[test]
706 fn no_reference_collects_nothing() {
707 assert!(collect(r#"{"a":1,"b":["x",{"c":null}]}"#).is_empty());
708 assert!(collect(r#"{}"#).is_empty());
709 assert!(collect(r#""just a string""#).is_empty());
710 }
711
712 #[test]
713 fn lookalikes_are_ignored() {
714 for doc in [
715 r#"{"type":"Referenced","data":{"providerName":"p","secretName":"a"},"x":1}"#,
717 r#"{"type":"referenced","data":{"providerName":"p","secretName":"a"}}"#,
719 r#"{"type":"Referenced","data":{"providerName":"p","secretName":7}}"#,
721 r#"{"type":"Referenced","data":{"providerName":"p","secretName":"a","extra":"v"}}"#,
723 r#"{"type":"Referenced","data":{"secretName":"a"}}"#,
725 r#"{"type":"Embedded","data":{"ciphertext":"X"}}"#,
727 ] {
728 assert!(collect(doc).is_empty(), "{doc}");
729 }
730 }
731
732 #[test]
733 fn a_lookalike_does_not_hide_a_reference_inside_it() {
734 let doc = format!(r#"{{"type":"Referenced","extra":{REF_A}}}"#);
735 assert_eq!(collect(&doc), [pair("p", "a")]);
736 }
737
738 fn key(module: &str, ty: &str) -> ResourceKey {
739 ResourceKey {
740 module: module.to_string(),
741 ty: ty.to_string(),
742 name: Some("x".to_string()),
743 }
744 }
745
746 fn scan(module: &str, ty: &str, json: &str) -> SecretScan {
747 scan_secrets(
748 "f/config.json",
749 &key(module, ty),
750 "config.json",
751 &node::parse(json).unwrap(),
752 )
753 }
754
755 #[test]
756 fn embedded_nested_in_arrays_is_found_with_index_path() {
757 let s = scan(
758 "m",
759 "t",
760 r#"{"a":[{"b":{"type":"Embedded","data":{"ciphertext":"X"}}}]}"#,
761 );
762 assert_eq!(s.refused, ["f/config.json: a[0].b (embedded)"]);
763 }
764
765 #[test]
766 fn embedded_without_object_data_is_not_embedded() {
767 let s = scan("m", "t", r#"{"a":{"type":"Embedded","data":"x"}}"#);
768 assert!(s.refused.is_empty());
769 }
770
771 #[test]
772 fn embedded_at_a_root_is_reported_as_root() {
773 let s = scan_embedded(
774 "f.json",
775 &node::parse(r#"{"type":"Embedded","data":{}}"#).unwrap(),
776 );
777 assert_eq!(s.refused, ["f.json: $ (embedded)"]);
778 }
779
780 #[test]
781 fn schema_path_values() {
782 let ok = scan("ignition", "database-connection", r#"{"password":null}"#);
783 assert_eq!(ok, SecretScan::default());
784 let ok = scan(
785 "ignition",
786 "database-connection",
787 r#"{"password":{"type":"Referenced","data":{"providerName":"p","secretName":"s"}}}"#,
788 );
789 assert_eq!(ok, SecretScan::default());
790 for bad in [r#""x""#, r#""""#, "1", "true", "[]", "{}"] {
791 let s = scan(
792 "ignition",
793 "database-connection",
794 &format!(r#"{{"password":{bad}}}"#),
795 );
796 assert_eq!(
797 s.refused,
798 ["f/config.json: password (plaintext)"],
799 "value {bad}"
800 );
801 }
802 }
803
804 #[test]
805 fn non_config_files_get_only_the_embedded_rule() {
806 let node = node::parse(r#"{"password":"x"}"#).unwrap();
807 let s = scan_secrets(
808 "f/other.json",
809 &key("com.cirruslink.mqtt.engine.gateway", "server"),
810 "other.json",
811 &node,
812 );
813 assert_eq!(s, SecretScan::default());
814 }
815
816 #[test]
817 fn key_name_in_a_described_type_warns_by_path() {
818 let s = scan(
819 "ignition",
820 "database-connection",
821 r#"{"settings":{"mqttPassword":"CANARY"}}"#,
822 );
823 assert!(s.refused.is_empty());
824 assert_eq!(s.warnings.len(), 1);
825 assert!(s.warnings[0].contains("settings.mqttPassword"));
826 assert!(!s.warnings[0].contains("CANARY"));
827 }
828
829 #[test]
830 fn referenced_data_secret_name_is_a_name() {
831 let s = scan(
832 "com.cirruslink.mqtt.engine.gateway",
833 "server",
834 r#"{"password":{"type":"Referenced","data":{"providerName":"p","secretName":"s"}}}"#,
835 );
836 assert_eq!(s, SecretScan::default());
837 }
838
839 #[test]
840 fn referenced_with_extra_data_member_at_schema_path_is_plaintext() {
841 let s = scan(
842 "ignition",
843 "database-connection",
844 r#"{"password":{"type":"Referenced","data":{"providerName":"p","secretName":"s","password":"hunter2"}}}"#,
845 );
846 assert_eq!(s.refused, ["f/config.json: password (plaintext)"]);
847 assert!(s.warnings.is_empty(), "{:?}", s.warnings);
848 }
849
850 #[test]
851 fn key_name_rule_applies_inside_malformed_referenced() {
852 let s = scan(
853 "com.cirruslink.mqtt.engine.gateway",
854 "server",
855 r#"{"settings":{"cred":{"type":"Referenced","data":{"providerName":"p","secretName":"s","password":"hunter2"}}}}"#,
856 );
857 assert_eq!(
858 s.refused,
859 [
860 "f/config.json: settings.cred.data.secretName (plaintext, by key name)",
861 "f/config.json: settings.cred.data.password (plaintext, by key name)",
862 ]
863 );
864 }
865
866 const MALFORMED_REFERENCED: [&str; 14] = [
869 r#"{"type":"Referenced","data":{"providerName":"p","secretName":"s","password":"x"}}"#,
870 r#"{"type":"Referenced","data":{"providerName":"p"}}"#,
871 r#"{"type":"Referenced","data":{"secretName":"s"}}"#,
872 r#"{"type":"Referenced","data":{}}"#,
873 r#"{"type":"Referenced","data":{"providerName":1,"secretName":"s"}}"#,
874 r#"{"type":"Referenced","data":{"providerName":"p","secretName":null}}"#,
875 r#"{"type":"Referenced","data":{"providerName":"p","secretName":{"v":"x"}}}"#,
876 r#"{"type":"Referenced","data":"x"}"#,
877 r#"{"type":"Referenced","data":[]}"#,
878 r#"{"type":"Referenced","data":{"providerName":"p","secretName":"s"},"value":"x"}"#,
879 r#"{"type":"referenced","data":{"providerName":"p","secretName":"s"}}"#,
880 r#"{"type":1,"data":{"providerName":"p","secretName":"s"}}"#,
881 r#"{"data":{"providerName":"p","secretName":"s"}}"#,
882 r#"{"type":"Referenced","data":{"providername":"p","secretName":"s"}}"#,
883 ];
884
885 #[test]
886 fn malformed_referenced_variants_are_plaintext_at_schema_path() {
887 for (i, variant) in MALFORMED_REFERENCED.iter().enumerate() {
888 let s = scan(
889 "ignition",
890 "database-connection",
891 &format!(r#"{{"password":{variant}}}"#),
892 );
893 assert_eq!(
894 s.refused,
895 ["f/config.json: password (plaintext)"],
896 "variant {i}"
897 );
898 assert!(s.warnings.is_empty(), "variant {i}: {:?}", s.warnings);
899 }
900 }
901
902 #[test]
903 fn well_formed_referenced_passes_in_either_key_order() {
904 for referenced in [
905 r#"{"data":{"providerName":"p","secretName":"s"},"type":"Referenced"}"#,
906 r#"{"type":"Referenced","data":{"secretName":"s","providerName":"p"}}"#,
907 ] {
908 let s = scan(
909 "ignition",
910 "database-connection",
911 &format!(r#"{{"password":{referenced}}}"#),
912 );
913 assert_eq!(s, SecretScan::default(), "{referenced}");
914 let s = scan(
915 "com.cirruslink.mqtt.engine.gateway",
916 "server",
917 &format!(
918 r#"{{"password":{referenced},"settings":{{"clientSecret":{referenced}}}}}"#
919 ),
920 );
921 assert_eq!(s, SecretScan::default(), "{referenced}");
922 }
923 }
924
925 #[test]
926 fn malformed_referenced_outside_schema_path_warns_in_described_type() {
927 let s = scan(
928 "ignition",
929 "database-connection",
930 r#"{"settings":{"cred":{"type":"Referenced","data":{"providerName":"p","secretName":"s","password":"hunter2"}}}}"#,
931 );
932 assert!(s.refused.is_empty(), "{:?}", s.refused);
933 assert_eq!(s.warnings.len(), 2, "{:?}", s.warnings);
934 assert!(
935 s.warnings
936 .iter()
937 .any(|w| w.contains("settings.cred.data.secretName;"))
938 );
939 assert!(
940 s.warnings
941 .iter()
942 .any(|w| w.contains("settings.cred.data.password;"))
943 );
944 assert!(s.warnings.iter().all(|w| !w.contains("hunter2")));
945 }
946
947 use proptest::prelude::*;
950
951 fn target(mode: &str, extra_nodes: &str) -> Target {
952 let yaml = format!(
953 "schema_version: 1\ngateway: g\nmode: {mode}\nnodes:\n master:\n profile: p\n{extra_nodes}"
954 );
955 Target::parse("t.yaml", yaml.as_bytes()).unwrap()
956 }
957
958 fn files(entries: &[(&str, &[u8])]) -> BTreeMap<RelPath, Vec<u8>> {
959 entries
960 .iter()
961 .map(|(p, b)| (RelPath::new(p).unwrap(), b.to_vec()))
962 .collect()
963 }
964
965 fn lint(entries: &[(&str, &[u8])], t: &Target) -> Vec<DeployFinding> {
966 lint_files(&files(entries), t, "dev", &|p| format!("src/{p}"))
967 }
968
969 fn texts(findings: &[DeployFinding]) -> String {
970 findings
971 .iter()
972 .map(ToString::to_string)
973 .collect::<Vec<_>>()
974 .join(" | ")
975 }
976
977 #[test]
978 fn case_fold_collision_names_both() {
979 let t = target("dev", "");
980 let found = lint(
981 &[
982 ("ignition/schedule/Pump/config.json", b"{}"),
983 ("ignition/schedule/pump/config.json", b"{}"),
984 ("ignition/schedule/Other/config.json", b"{}"),
985 ],
986 &t,
987 );
988 assert_eq!(found.len(), 1, "{}", texts(&found));
989 let text = texts(&found);
990 assert!(text.contains("`ignition/schedule/Pump`"), "{text}");
991 assert!(text.contains("`ignition/schedule/pump`"), "{text}");
992 assert!(!text.contains("Other"), "{text}");
993
994 let found = lint(&[("a/b/X", b""), ("a/b/x", b""), ("a/b/X.", b"")], &t);
996 let text = texts(&found);
997 assert!(
998 text.contains("`a/b/X`") && text.contains("`a/b/x`"),
999 "{text}"
1000 );
1001 assert!(lint(&[("a/b/x", b""), ("a/b/xy", b"")], &t).is_empty());
1003 assert_eq!(lint(&[("a/b/É", b""), ("a/b/é", b"")], &t).len(), 1);
1005 }
1006
1007 #[test]
1008 fn illegal_characters_are_refused() {
1009 let t = target("dev", "");
1010 for bad in [
1011 "a<b", "a>b", "a:b", "a\"b", "a|b", "a?b", "a*b", "a\u{1}b", "a\u{1f}b", "a\u{7f}b",
1012 ] {
1013 let found = lint(&[(&format!("m/t/{bad}/config.json"), b"{}")], &t);
1014 assert_eq!(found.len(), 1, "{bad:?}: {}", texts(&found));
1015 assert!(
1016 found[0].message.contains("does not allow in a name"),
1017 "{bad:?}: {}",
1018 texts(&found)
1019 );
1020 }
1021 let found = lint(&[("m/t/a:b/x.json", b"{}"), ("m/t/a:b/y.json", b"{}")], &t);
1023 assert_eq!(found.len(), 1, "{}", texts(&found));
1024 assert!(lint(&[("m/t/a b-c_d.e%3ab/x.json", b"{}")], &t).is_empty());
1026 }
1027
1028 #[test]
1029 fn trailing_dot_and_space_are_refused() {
1030 let t = target("dev", "");
1031 for bad in ["trail.", "trail ", "a b ", "dots.."] {
1032 let found = lint(&[(&format!("m/t/{bad}/x.json"), b"{}")], &t);
1033 assert_eq!(found.len(), 1, "{bad:?}: {}", texts(&found));
1034 assert!(found[0].message.contains("ends in"), "{}", texts(&found));
1035 }
1036 assert!(lint(&[("m/t/inner .dot/x.json", b"{}")], &t).is_empty());
1037 }
1038
1039 #[test]
1040 fn reserved_names_are_refused() {
1041 let t = target("dev", "");
1042 for stem in RESERVED_STEMS {
1043 for name in [stem.to_string(), stem.to_lowercase(), format!("{stem}.txt")] {
1044 let found = lint(&[(&format!("m/t/{name}/x.json"), b"{}")], &t);
1045 assert_eq!(found.len(), 1, "{name}: {}", texts(&found));
1046 assert!(found[0].message.contains("reserved"), "{}", texts(&found));
1047 }
1048 }
1049 for ok in [
1051 "CON1", "COM0", "COM10", "xcon", "CONSOLE", "LPT", "a.CON", "COMM",
1052 ] {
1053 assert!(
1054 lint(&[(&format!("m/t/{ok}/x.json"), b"{}")], &t).is_empty(),
1055 "{ok}"
1056 );
1057 }
1058 assert_eq!(lint(&[("m/t/n/nul.json", b"{}")], &t).len(), 1);
1060 }
1061
1062 #[test]
1063 fn component_length_boundary() {
1064 let t = target("d", "");
1065 let component = |path: &str| texts(&lint(&[(path, b"")], &t)).contains("name component");
1067 let long = |n: usize| format!("m/t/{}/x", "a".repeat(n));
1068 assert!(!component(&long(255)));
1069 assert!(component(&long(256)));
1070 assert!(texts(&lint(&[(&long(256), b"")], &t)).contains("256 UTF-16 code units"));
1071 assert!(!component(&format!("m/t/{}a/x", "\u{1F600}".repeat(127))));
1073 assert!(component(&format!("m/t/{}/x", "\u{1F600}".repeat(128))));
1074 assert!(!component(&format!("m/t/{}/x", "\u{e9}".repeat(255))));
1076 }
1077
1078 fn path_of(total: usize) -> String {
1080 let mut path = String::from("m/t/n/");
1081 path.push_str(&"a".repeat(total - path.len()));
1082 path
1083 }
1084
1085 #[test]
1086 fn path_budget_boundary() {
1087 let t = target("production", "");
1090 assert_eq!(windows_prefix_len(&t), 79);
1091 let ok = path_of(167);
1092 assert!(lint(&[(&ok, b"")], &t).is_empty(), "247 passes");
1093 let bad = path_of(168);
1094 let found = lint(&[(&bad, b"")], &t);
1095 assert_eq!(found.len(), 1, "{}", texts(&found));
1096 assert!(
1097 found[0].message.contains("248 UTF-16 code units"),
1098 "{}",
1099 texts(&found)
1100 );
1101
1102 let dir = format!("D:\\{}", "d".repeat(60));
1105 let windows = format!(
1106 " ssh:\n host: h\n user: u\n os: windows\n data_dir: '{dir}'\n known_hosts: kh\n"
1107 );
1108 let t2 = target("production", &windows);
1109 assert_eq!(windows_prefix_len(&t2), 79 + 12);
1110 assert!(lint(&[(&path_of(167 - 12), b"")], &t2).is_empty());
1111 assert_eq!(lint(&[(&path_of(168 - 12), b"")], &t2).len(), 1);
1112
1113 let backup = " backup:\n profile: b\n ssh:\n host: h\n user: u\n os: windows\n data_dir: 'C:\\Program Files\\Inductive Automation\\Ignition\\data\\ab\\'\n known_hosts: kh\n";
1115 assert_eq!(windows_prefix_len(&target("production", backup)), 79 + 3);
1116
1117 let linux = format!(
1119 " ssh:\n host: h\n user: u\n os: linux\n data_dir: /{}\n known_hosts: kh\n",
1120 "l".repeat(100)
1121 );
1122 assert_eq!(windows_prefix_len(&target("production", &linux)), 79);
1123 }
1124
1125 #[test]
1126 fn mode_is_held_to_the_component_rules() {
1127 let t = target("prod.", "");
1128 let found = lint(&[], &t);
1129 assert_eq!(found.len(), 1, "{}", texts(&found));
1130 assert_eq!(found[0].file, "targets/dev.yaml");
1131 assert!(found[0].message.contains("mode `prod.`"));
1132 assert_eq!(lint(&[], &target("CON", "")).len(), 1);
1133 }
1134
1135 #[test]
1136 fn cr_in_text_member_is_refused_with_line() {
1137 let t = target("dev", "");
1138 let found = lint(
1139 &[("m/t/n/config.json", b"{\n \"a\": 1,\r\n \"b\": 2\n}")],
1140 &t,
1141 );
1142 assert_eq!(found.len(), 1, "{}", texts(&found));
1143 assert_eq!((found[0].line, found[0].column), (Some(2), Some(10)));
1144 assert_eq!(found[0].file, "src/m/t/n/config.json");
1145 assert!(
1146 found[0]
1147 .to_string()
1148 .starts_with("src/m/t/n/config.json:2:10: "),
1149 "{}",
1150 found[0]
1151 );
1152 let found = lint(&[("m/t/n/a.txt", b"x\ry\r\n")], &t);
1154 assert_eq!(found[0].line, Some(1));
1155 }
1156
1157 #[test]
1158 fn cr_in_jar_is_not_refused() {
1159 let t = target("dev", "");
1160 assert!(lint(&[("m/t/n/driver.jar", b"PK\r\n\x00\x01")], &t).is_empty());
1162 assert!(lint(&[("m/t/n/driver.bin", b"\xff\xfe\r\n")], &t).is_empty());
1164 }
1165
1166 #[test]
1167 fn cr_in_unknown_text_extension_is_refused() {
1168 let t = target("dev", "");
1169 for ext in [
1170 "json",
1171 "css",
1172 "yaml",
1173 "yml",
1174 "txt",
1175 "md",
1176 "xml",
1177 "py",
1178 "csv",
1179 "properties",
1180 "html",
1181 "js",
1182 "svg",
1183 "JSON",
1184 "Xml",
1185 "weird",
1186 "noext",
1187 ] {
1188 let name = if ext == "noext" {
1189 "m/t/n/LICENSE".to_string()
1190 } else {
1191 format!("m/t/n/f.{ext}")
1192 };
1193 let found = lint(&[(&name, b"a\r\nb")], &t);
1194 assert_eq!(found.len(), 1, "{name}: {}", texts(&found));
1195 }
1196 assert!(is_text_member("a.json", b"\x00\xff"));
1198 assert!(!is_text_member("a.jar", b"\x00PK"));
1199 assert!(is_text_member("a.jar", b"plain"));
1200 assert!(!is_text_member("dir.json/blob", b"\x00"));
1202 }
1203
1204 #[test]
1205 fn empty_inputs_pass() {
1206 let t = target("dev", "");
1207 assert!(lint(&[], &t).is_empty());
1208 assert!(lint(&[("m/t/n/empty.txt", b""), ("m/t/n/empty.bin", b"")], &t).is_empty());
1209 }
1210
1211 fn reserved(name: &str) -> bool {
1212 RESERVED_STEMS.contains(
1213 &name
1214 .split('.')
1215 .next()
1216 .unwrap()
1217 .to_ascii_uppercase()
1218 .as_str(),
1219 )
1220 }
1221
1222 proptest! {
1223 #[test]
1224 fn prop_extra_data_member_is_refused_without_value(
1225 key in "[A-Za-z][A-Za-z0-9]{0,12}",
1226 value in "CANARY-[A-Za-z0-9]{6,12}",
1227 ) {
1228 prop_assume!(key != "providerName" && key != "secretName");
1229 let json = format!(
1230 r#"{{"password":{{"type":"Referenced","data":{{"providerName":"p","secretName":"s","{key}":"{value}"}}}}}}"#
1231 );
1232 let s = scan("ignition", "database-connection", &json);
1233 prop_assert_eq!(&s.refused, &["f/config.json: password (plaintext)".to_string()]);
1234 for text in s.refused.iter().chain(s.warnings.iter()) {
1235 prop_assert!(!text.contains(&value), "value leaked: {text}");
1236 }
1237 }
1238
1239 #[test]
1240 fn prop_illegal_char_names_refused(
1241 base in "[A-Za-z0-9_-]{1,20}",
1242 at in 0usize..=20,
1243 bad in prop::sample::select(vec!['<', '>', ':', '"', '|', '?', '*', '\u{1}', '\u{8}', '\u{1f}']),
1244 ) {
1245 let mut name = base.clone();
1246 name.insert(at.min(base.len()), bad);
1247 let found = lint(
1248 &[(&format!("m/t/{name}/x.json"), b"{}")],
1249 &target("dev", ""),
1250 );
1251 prop_assert!(!found.is_empty(), "{name:?} passed");
1252 }
1253
1254 #[test]
1255 fn prop_legal_names_pass(name in "[A-Za-z0-9_-]([A-Za-z0-9_ .-]{0,30}[A-Za-z0-9_-])?") {
1256 prop_assume!(!reserved(&name));
1257 let found = lint(
1258 &[(&format!("m/t/{name}/x.json"), b"{}")],
1259 &target("dev", ""),
1260 );
1261 prop_assert!(found.is_empty(), "{name:?}: {}", texts(&found));
1262 }
1263 }
1264}
1265
1266#[cfg(test)]
1269mod tag_node_tests {
1270 use super::*;
1271 use crate::deploy::node;
1272
1273 const ORIGIN: &str = "tags/PlantA/F/ApiToken.json";
1274
1275 fn scan(path: &str, json: &str, sends: Sends) -> SecretScan {
1276 scan_tag_node(ORIGIN, path, &node::parse(json).unwrap(), sends)
1277 }
1278
1279 fn all_text(s: &SecretScan) -> String {
1280 format!("{:?} {:?}", s.refused, s.warnings)
1281 }
1282
1283 #[test]
1286 fn credential_values_are_strings_at_any_depth() {
1287 for value in [
1288 r#""fake-token-aaaa""#,
1289 r#"{"jwt":"fake.jwt.value"}"#,
1290 r#"{"outer":{"inner":["", "fake"]}}"#,
1291 r#"["fake"]"#,
1292 ] {
1293 let s = scan(
1294 "F/ApiToken",
1295 &format!(r#"{{"tagType":"AtomicTag","value":{value}}}"#),
1296 Sends::All,
1297 );
1298 assert_eq!(s.refused.len(), 1, "{value}: {}", all_text(&s));
1299 }
1300 for value in [r#""""#, "42", "true", "null", r#"{"n":1,"a":[""]}"#, "[]"] {
1301 let s = scan(
1302 "F/ApiToken",
1303 &format!(r#"{{"tagType":"AtomicTag","value":{value}}}"#),
1304 Sends::All,
1305 );
1306 assert_eq!(s, SecretScan::default(), "{value}");
1307 }
1308 }
1309
1310 #[test]
1313 fn markers_match_the_tag_name_without_case() {
1314 assert_eq!(TAG_CREDENTIAL_MARKERS.len(), 7);
1315 for name in [
1316 "DB_PASSWORD",
1317 "Passwd1",
1318 "MySecret",
1319 "authToken",
1320 "APIKEY",
1321 "svc_api_key",
1322 "Credentials",
1323 ] {
1324 let s = scan(
1325 &format!("F/{name}"),
1326 r#"{"tagType":"AtomicTag","value":"fake"}"#,
1327 Sends::All,
1328 );
1329 assert_eq!(s.refused.len(), 1, "{name}");
1330 }
1331 let s = scan(
1332 "F/Speed",
1333 r#"{"tagType":"AtomicTag","value":"fake","defaultValue":"fake"}"#,
1334 Sends::All,
1335 );
1336 assert_eq!(s, SecretScan::default());
1337 }
1338
1339 #[test]
1342 fn all_refuses_value_default_value_and_embedded() {
1343 let s = scan(
1344 "F/ApiToken",
1345 r#"{"tagType":"AtomicTag","value":"fake-a","defaultValue":"fake-b",
1346 "documentation":{"type":"Embedded","data":{"ciphertext":"X"}}}"#,
1347 Sends::All,
1348 );
1349 assert_eq!(
1350 s.refused,
1351 [
1352 format!("{ORIGIN}: documentation (embedded)"),
1353 format!("{ORIGIN}: value (credential value)"),
1354 format!("{ORIGIN}: defaultValue (credential value)"),
1355 ]
1356 );
1357 assert!(s.warnings.is_empty());
1358 }
1359
1360 #[test]
1364 fn config_refuses_default_value_and_warns_value() {
1365 let s = scan(
1366 "F/ApiToken",
1367 r#"{"tagType":"AtomicTag","value":"fake-a","defaultValue":"fake-b"}"#,
1368 Sends::Config,
1369 );
1370 assert_eq!(
1371 s.refused,
1372 [format!("{ORIGIN}: defaultValue (credential value)")]
1373 );
1374 assert_eq!(s.warnings.len(), 1);
1375 assert!(s.warnings[0].starts_with(&format!("{ORIGIN}: value (credential value)")));
1376 assert!(
1377 s.warnings[0].contains("live value is kept"),
1378 "{:?}",
1379 s.warnings
1380 );
1381 let s = scan(
1382 "F/Speed",
1383 r#"{"tagType":"AtomicTag","tooltip":{"type":"Embedded","data":{"k":"X"}}}"#,
1384 Sends::Config,
1385 );
1386 assert_eq!(s.refused, [format!("{ORIGIN}: tooltip (embedded)")]);
1387 }
1388
1389 #[test]
1392 fn nothing_only_warns() {
1393 let s = scan(
1394 "F/ApiToken",
1395 r#"{"tagType":"AtomicTag","value":"fake-a","defaultValue":"fake-b",
1396 "tooltip":{"type":"Embedded","data":{"k":"X"}}}"#,
1397 Sends::Nothing,
1398 );
1399 assert!(s.refused.is_empty(), "{}", all_text(&s));
1400 assert_eq!(s.warnings.len(), 3, "{:?}", s.warnings);
1401 assert!(s.warnings.iter().all(|w| w.contains("nothing is sent")));
1402 assert!(s.warnings[0].starts_with(&format!("{ORIGIN}: tooltip (embedded)")));
1403 }
1404
1405 #[test]
1409 fn document_default_value_refuses_when_sent() {
1410 let json = r#"{"tagType":"AtomicTag","dataType":"Document",
1411 "value":{"count":2},"defaultValue":{"jwt":"fake.jwt.part"}}"#;
1412 for sends in [Sends::All, Sends::Config] {
1413 let s = scan("F/ApiToken", json, sends);
1414 assert_eq!(
1415 s.refused,
1416 [format!("{ORIGIN}: defaultValue (credential value)")],
1417 "{sends:?}"
1418 );
1419 }
1420 let s = scan("F/ApiToken", json, Sends::Nothing);
1421 assert!(s.refused.is_empty() && s.warnings.len() == 1);
1422 }
1423
1424 #[test]
1426 fn instance_members_are_scanned() {
1427 let json = r#"{"tagType":"UdtInstance","typeId":"Device","tags":[
1428 {"name":"Speed","tagType":"AtomicTag","value":"fake"},
1429 {"name":"Sub","tagType":"Folder","tags":[
1430 {"name":"LoginPassword","tagType":"AtomicTag","value":"fake-p"}]}]}"#;
1431 let s = scan("F/Device", json, Sends::All);
1432 assert_eq!(
1433 s.refused,
1434 [format!(
1435 "{ORIGIN}: tags.Sub.tags.LoginPassword.value (credential value)"
1436 )]
1437 );
1438 let s = scan("F/Device", json, Sends::Config);
1439 assert!(s.refused.is_empty());
1440 assert_eq!(s.warnings.len(), 1);
1441 }
1442
1443 #[test]
1446 fn a_changed_type_sends_member_values() {
1447 let json = r#"{"tagType":"UdtType","tags":[
1448 {"name":"ApiKey","tagType":"AtomicTag","value":"fake-k"}]}"#;
1449 let s = scan("_types_/Device", json, Sends::Config);
1450 assert_eq!(
1451 s.refused,
1452 [format!("{ORIGIN}: tags.ApiKey.value (credential value)")]
1453 );
1454 }
1455
1456 #[test]
1458 fn outputs_never_carry_a_value() {
1459 let json = r#"{"tagType":"AtomicTag","value":"sentinel-one",
1460 "defaultValue":{"jwt":"sentinel-two"},
1461 "tooltip":{"type":"Embedded","data":{"k":"sentinel-three"}}}"#;
1462 for sends in [Sends::All, Sends::Config, Sends::Nothing] {
1463 let s = scan("F/ApiToken", json, sends);
1464 assert!(
1465 !all_text(&s).contains("sentinel"),
1466 "{sends:?}: {}",
1467 all_text(&s)
1468 );
1469 assert!(!s.refused.is_empty() || !s.warnings.is_empty());
1470 }
1471 }
1472}