1mod cache;
5mod code_spans;
6mod compile;
7mod jslex;
8mod pattern;
9mod prefilter;
10mod prove;
11pub mod range;
12mod scan;
13pub mod secrets;
14pub mod terms;
15
16use crate::check::baseline;
17use crate::check::finding::Finding;
18use crate::config::{Config, GuardDef};
19use anyhow::{Result, anyhow, bail};
20pub use range::GuardRange;
21
22fn select<'a>(cfg: &'a Config, names: &[String]) -> Result<Vec<&'a GuardDef>> {
23 if names.is_empty() {
24 return Ok(cfg.raw.guards.iter().collect());
25 }
26 names
27 .iter()
28 .map(|n| {
29 cfg.raw.guards.iter().find(|g| &g.name == n).ok_or_else(|| {
30 let have: Vec<&str> = cfg.raw.guards.iter().map(|g| g.name.as_str()).collect();
31 anyhow!("no guard named '{n}' (have: {})", have.join(", "))
32 })
33 })
34 .collect()
35}
36
37fn scan_one(cfg: &Config, g: &GuardDef, range: Option<&GuardRange>) -> Result<scan::Scanned> {
38 let c = compile::compile(cfg, g)?;
39 prove::prove(g, &c)?;
40 let r = range.cloned().unwrap_or_else(|| GuardRange::parse(&g.range));
41 scan::run(cfg, g, &c, &range::resolve(&cfg.root, &r, &c.bases)?)
42}
43
44pub fn run(cfg: &Config, names: &[String], range: Option<&GuardRange>) -> Result<Vec<Finding>> {
47 let mut out = vec![];
48 for g in select(cfg, names)? {
49 let found = scan_one(cfg, g, range)?.findings;
50 match &g.baseline {
51 Some(rel) => match baseline::load_file(&cfg.root, rel)? {
52 Some(b) => out.extend(baseline::hold_with(&b, found, &[]).findings),
53 None => out.extend(found),
54 },
55 None => out.extend(found),
56 }
57 }
58 Ok(out)
59}
60
61pub fn write_baselines(cfg: &Config, names: &[String]) -> Result<Vec<(String, String, usize)>> {
63 let mut written = vec![];
64 for g in select(cfg, names)? {
65 let Some(rel) = &g.baseline else {
66 if !names.is_empty() {
67 bail!("guard {} has no baseline file; set baseline = \"…\" on it", g.name);
68 }
69 continue;
70 };
71 let scanned = scan_one(cfg, g, Some(&GuardRange::All))?;
72 let b = baseline::measure_with(&cfg.root, &scanned.findings, &[], |p| scanned.files.iter().any(|f| f == p))?;
73 baseline::write_file(&cfg.root, rel, &b)?;
74 written.push((g.name.clone(), rel.clone(), b.counts.values().sum()));
75 }
76 Ok(written)
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use crate::config::RawConfig;
83
84 fn config(root: &std::path::Path, guard: &str) -> Config {
85 let raw: RawConfig = toml::from_str(&format!("[[guards]]\n{guard}")).unwrap();
86 Config::from_raw(root.to_path_buf(), raw, false)
87 }
88
89 #[test]
90 fn scans_tree() {
91 let tmp = tempfile::tempdir().unwrap();
92 let r = tmp.path();
93 std::fs::create_dir_all(r.join("libs/open/src")).unwrap();
94 std::fs::create_dir_all(r.join("services/shop")).unwrap();
95 let fake_key = format!("{}{}", "AK", "IA".to_string() + &"X".repeat(16));
96 std::fs::write(r.join("libs/open/src/a.rs"), format!("// built for Acme-Internal\nlet k = \"{fake_key}\";\n"))
97 .unwrap();
98 std::fs::write(r.join("libs/open/.env"), "X=1\n").unwrap();
99 std::fs::write(r.join("services/shop/b.rs"), "acme-internal is fine here\n").unwrap();
100 std::fs::write(r.join("terms.txt"), "# private\nacme-internal\n").unwrap();
101 let cfg = config(
102 r,
103 "name = \"public\"\npaths = [\"libs/open/**\"]\nrange = \"all\"\ndeny_terms_file = \"terms.txt\"\ndeny_files = [\"**/.env*\"]\nsecrets = true\n",
104 );
105 let mut f = run(&cfg, &[], None).unwrap();
106 f.sort_by_key(|a| (a.path.clone(), a.line));
107 let got: Vec<(String, usize)> = f.iter().map(|x| (x.path.clone(), x.line)).collect();
108 assert_eq!(
109 got,
110 [
111 ("libs/open/.env".to_string(), 1),
112 ("libs/open/src/a.rs".to_string(), 1),
113 ("libs/open/src/a.rs".to_string(), 2)
114 ]
115 );
116 assert!(!f[1].message.contains("acme-internal"));
117 }
118
119 #[test]
120 fn patterns_skip_urls_exemptions_and_allowed_text() {
121 let tmp = tempfile::tempdir().unwrap();
122 let r = tmp.path();
123 std::fs::create_dir_all(r.join("pub/docs")).unwrap();
124 std::fs::write(
125 r.join("pub/a.md"),
126 "see https://example.com/internal/x\ninternal/build.sh\nthe internal/README example\n",
127 )
128 .unwrap();
129 std::fs::write(r.join("pub/docs/b.md"), "internal/build.sh\n").unwrap();
130 let cfg = config(
131 r,
132 "name = \"g\"\npaths = [\"pub/**\"]\nrange = \"all\"\nallow = [\"internal/readme\"]\n[[guards.deny_regex]]\nre = 'internal/[a-z.]+'\nlabel = \"internal path\"\nnot_in_url = true\nexempt = [\"pub/docs/**\"]\nmust_hit = [\"run internal/build.sh\"]\nmust_pass = [\"https://example.com/internal/x\"]\n",
133 );
134 let f = run(&cfg, &[], None).unwrap();
135 let got: Vec<(String, usize)> = f.iter().map(|x| (x.path.clone(), x.line)).collect();
136 assert_eq!(got, [("pub/a.md".to_string(), 2)], "{f:?}");
137 assert!(f[0].message.contains("internal path"));
138 }
139
140 #[test]
141 fn a_fixture_that_no_longer_holds_stops_the_run() {
142 let tmp = tempfile::tempdir().unwrap();
143 let cfg = config(
144 tmp.path(),
145 "name = \"g\"\nrange = \"all\"\n[[guards.deny_regex]]\nre = 'secret-[0-9]+'\nmust_hit = [\"secret-abc\"]\n",
146 );
147 let e = run(&cfg, &[], None).unwrap_err().to_string();
148 assert!(e.contains("does not match its must_hit fixture"), "{e}");
149 }
150
151 #[test]
152 fn a_whole_tree_run_over_nothing_fails_and_gzip_is_read() {
153 use std::io::Write;
154 let tmp = tempfile::tempdir().unwrap();
155 let r = tmp.path();
156 let none = config(r, "name = \"g\"\npaths = [\"missing/**\"]\nrange = \"all\"\ndeny_terms = [\"x\"]\n");
157 assert!(run(&none, &[], None).unwrap()[0].message.contains("scanned no file"));
158 std::fs::create_dir_all(r.join("pkg")).unwrap();
159 let mut gz = flate2::write::GzEncoder::new(vec![], flate2::Compression::default());
160 gz.write_all(b"hello acme-internal\n").unwrap();
161 std::fs::write(r.join("pkg/a.txt.gz"), gz.finish().unwrap()).unwrap();
162 let cfg = config(
163 r,
164 "name = \"g\"\npaths = [\"pkg/**\"]\nrange = \"all\"\ndeny_terms = [\"acme-internal\"]\ndecompress = true\n",
165 );
166 assert_eq!(run(&cfg, &[], None).unwrap().len(), 1);
167 }
168
169 #[test]
170 fn an_empty_or_missing_published_path_is_a_hit() {
171 let tmp = tempfile::tempdir().unwrap();
172 let r = tmp.path();
173 std::fs::create_dir_all(r.join("pub/full")).unwrap();
174 std::fs::create_dir_all(r.join("pub/empty")).unwrap();
175 std::fs::write(r.join("pub/full/a.md"), "fine\n").unwrap();
176 let raw: RawConfig = toml::from_str(
177 "[public]\npaths = [\"pub/full\", \"pub/empty\", \"pub/gone\"]\n[[guards]]\nname = \"g\"\npublic = true\nrange = \"all\"\ndeny_terms = [\"x-internal\"]\n",
178 )
179 .unwrap();
180 let cfg = Config::from_raw(r.to_path_buf(), raw, false);
181 let mut got: Vec<(String, bool)> =
182 run(&cfg, &[], None).unwrap().into_iter().map(|f| (f.path, f.message.contains("not present"))).collect();
183 got.sort();
184 assert_eq!(got, [("pub/empty".to_string(), false), ("pub/gone".to_string(), true)]);
185 }
186}