use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub file: String,
pub line: usize,
pub kind: &'static str,
pub message: String,
}
pub fn run(root: &Path) -> Result<(), String> {
let (noted, findings): (Vec<Finding>, Vec<Finding>) =
audit(root).into_iter().partition(|f| f.kind == "unscoped");
for f in ¬ed {
println!("{}:{}: {}: {}", f.file, f.line, f.kind, f.message);
}
for f in &findings {
println!("{}:{}: {}: {}", f.file, f.line, f.kind, f.message);
}
match findings.len() {
0 => {
println!("No findings.");
Ok(())
}
n => Err(format!(
"{n} finding{} to look at",
if n == 1 { "" } else { "s" }
)),
}
}
pub fn audit(root: &Path) -> Vec<Finding> {
let mut files = Vec::new();
collect(&root.join("src"), &mut files);
let mut findings = Vec::new();
let read: Vec<(String, String)> = files
.iter()
.filter_map(|path| {
let text = fs::read_to_string(path).ok()?;
let shown = path
.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string();
Some((shown, text))
})
.collect();
let mut foreign_keys: Vec<(String, Vec<String>)> = Vec::new();
for (file, text) in &read {
if !file.contains("models/") || !file.ends_with(".glacier") {
continue;
}
let Some(model) = model_name(text) else {
continue;
};
let keys: Vec<String> = text
.lines()
.filter_map(|l| l.trim().strip_prefix("belongs_to :"))
.map(|rest| {
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
format!("{name}_id")
})
.collect();
if !keys.is_empty() {
foreign_keys.push((model, keys));
}
}
for (file, text) in &read {
let code = file.ends_with(".glacier") || file.ends_with(".rs");
let template = file.ends_with(".html");
let for_someone = file.contains("controllers/")
&& text
.split(|c: char| !c.is_alphanumeric() && c != '_')
.any(|word| word == "access");
let lines: Vec<&str> = text.lines().collect();
for (i, raw_line) in lines.iter().enumerate() {
let line = raw_line.trim();
if line.starts_with('#') || line.starts_with("//") {
continue;
}
let at = i + 1;
let mut find = |kind: &'static str, message: String| {
findings.push(Finding {
file: file.clone(),
line: at,
kind,
message,
});
};
if code
&& (line.starts_with("unscoped ")
|| line.contains("access::unscoped(")
|| line.contains("glacier::access::unscoped("))
{
let reason = line
.split('"')
.nth(1)
.map_or("no reason given".to_owned(), str::to_owned);
find(
"unscoped",
format!(
"reads past the access ({reason}): check only the rows it should see are read"
),
);
}
if (code || template) && contains_call(line, "raw") {
find(
"raw-html",
"`raw(…)` skips escaping: make sure nothing a user wrote reaches it".into(),
);
}
if code
&& for_someone
&& [
"cache::fetch(",
"cache::fetch_flexible(",
"Cache.fetch(",
"Cache.fetch_flexible(",
]
.iter()
.any(|call| line.contains(call))
&& !line.contains("key_for")
{
find(
"cache-key",
"a value cached by an action for someone: if it's theirs, `cache::fetch_for(access, …)` keeps it from everyone else".into(),
);
}
if code
&& ["db::sql(", "db.sql(", "Db.sql(", "Db::sql("]
.iter()
.any(|call| line.contains(call))
&& (line.contains("format!(") || line.contains("#{"))
{
find(
"sql-string",
"SQL built from a string: bind values with `.bind(…)` and `?` instead".into(),
);
}
if code
&& ["order_raw(", "where_sql(", "Expr.raw(", "Expr::raw("]
.iter()
.any(|call| line.contains(call))
&& (line.contains("format!(")
|| line.contains("#{")
|| line.contains("params[")
|| line.contains("params.get(")
|| line.contains("ctx.params()"))
{
find(
"sql-string",
"SQL from a string the request may set: for an order, `.sort(¶ms, [\"title\", …])` takes only the columns listed; bind values with `?` instead".into(),
);
}
if code && (line.contains("Cors::any_origin()") || line.contains("Cors.any_origin()")) {
find(
"cors-any",
"CORS allows any origin: list the origins (`Cors.origins([…])`), or keep it to public, read-only routes".into(),
);
}
if code && file.contains("controllers/") {
for (model, keys) in &foreign_keys {
for call in [
"build(params",
"update(params",
"assign(¶ms",
"build(ctx.params()",
] {
let model_call = format!("{model}.{call}");
let rust_call = format!("{model}::{call}");
let on_record = call.starts_with("update") || call.starts_with("assign");
let called = line.contains(&model_call)
|| line.contains(&rust_call)
|| (on_record
&& line.contains(&format!(".{call}"))
&& line.to_lowercase().contains(&model.to_lowercase()));
if !called {
continue;
}
let after = &lines[i + 1..lines.len().min(i + 8)];
let open: Vec<&String> = keys
.iter()
.filter(|key| {
!after.iter().any(|l| l.contains(&format!(".{key} =")))
&& !line.contains(&format!("without(\"{key}\""))
})
.collect();
if !open.is_empty() {
find(
"castable-key",
format!(
"{} can be set from the request's params here: set {} from the session (or a lookup) after, or cast only the fields you mean",
open.iter()
.map(|k| k.as_str())
.collect::<Vec<_>>()
.join(", "),
if open.len() == 1 { "it" } else { "them" }
),
);
}
break;
}
}
}
}
if file.ends_with("routes.glacier") || file.ends_with("routes.rs") {
findings.extend(route_findings(file, text));
}
}
findings
}
fn route_findings(file: &str, text: &str) -> Vec<Finding> {
let mut findings = Vec::new();
let mut scopes: Vec<(usize, String)> = Vec::new();
let browser_app = text.contains("Pipeline.browser()") || text.contains("Pipeline::browser()");
for (i, raw_line) in text.lines().enumerate() {
let indent = raw_line.len() - raw_line.trim_start().len();
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
continue;
}
scopes.retain(|(depth, _)| *depth < indent);
if line.starts_with("scope ") {
scopes.push((indent, line.to_owned()));
continue;
}
let verb = line.split_whitespace().next().unwrap_or_default();
if !["get", "post", "put", "patch", "delete"].contains(&verb) {
continue;
}
let action = line
.split([',', ';'])
.nth(if line.contains("=>") { 0 } else { 1 })
.map(|part| {
part.rsplit(['.', ':'])
.next()
.unwrap_or_default()
.trim()
.to_owned()
})
.unwrap_or_default();
let changing = [
"create", "update", "destroy", "delete", "remove", "publish", "archive", "cancel",
"approve", "reject",
]
.iter()
.any(|word| action == *word || action.starts_with(&format!("{word}_")));
if verb == "get" && changing {
findings.push(Finding {
file: file.to_owned(),
line: i + 1,
kind: "get-changes",
message: format!(
"GET runs `{action}`: a link, a prefetch or a crawler can trigger it; use post, patch or delete"
),
});
}
let webhook = line.contains("webhooks.receive") || line.contains("webhooks::receive");
let through = scopes.iter().any(|(_, s)| s.contains("through"));
if browser_app && verb != "get" && !webhook && !through {
findings.push(Finding {
file: file.to_owned(),
line: i + 1,
kind: "no-csrf",
message: "a route that changes things outside any pipeline, so with no CSRF check: put it in a scope through :browser (or :api for token-authenticated JSON)".into(),
});
}
}
findings
}
fn model_name(text: &str) -> Option<String> {
text.lines().find_map(|l| {
let rest = l
.strip_prefix("module ")
.or_else(|| l.strip_prefix("model "))?;
Some(
rest.split(|c: char| !c.is_alphanumeric() && c != '_')
.next()?
.to_owned(),
)
})
}
fn contains_call(line: &str, name: &str) -> bool {
let pattern = format!("{name}(");
line.match_indices(&pattern).any(|(at, _)| {
let before = &line[..at];
!before.ends_with(|c: char| c.is_alphanumeric() || c == '_')
&& (!before.ends_with('.') || before.ends_with("helpers."))
})
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect(&path, out);
} else if path
.extension()
.is_some_and(|e| e == "glacier" || e == "rs" || e == "html")
{
out.push(path);
}
}
out.sort();
}
#[cfg(test)]
mod tests {
use super::*;
fn app(files: &[(&str, &str)]) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"glacier-audit-{}-{}",
std::process::id(),
files.len()
));
let _ = fs::remove_dir_all(&root);
for (path, text) in files {
let path = root.join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
root
}
#[test]
fn finds_what_to_look_at() {
let root = app(&[
(
"src/models/post.glacier",
"module Post do\n implements Model\n belongs_to :user\n field title: String\nend\n",
),
(
"src/controllers/posts_controller.glacier",
"module PostsController do\n implements Controller\n\n def create do\n post = Post.build(params)\n rows = glacier::db::sql(format!(\"SELECT {}\", 1)).fetch_all()!\n more = Db.sql(\"SELECT #{post.title}\").fetch_all()!\n end\nend\n",
),
("src/views/posts/show.html", "<p>{{ raw(post.body) }}</p>\n"),
(
"src/controllers/dashboards_controller.glacier",
"module DashboardsController do\n implements Controller\n\n def show do\n count = glacier::cache::fetch(\"count\", 1.hours(), fn -> Note.for(access).count() end)!\n mine = glacier::cache::fetch_for(&access, \"count\", 1.hours(), fn -> Note.for(access).count() end)!\n end\nend\n",
),
(
"src/routes.glacier",
"routes do\n pipeline browser = Pipeline.browser()\n\n scope \"/\", through: :browser do\n get \"/posts/:id/delete\", Posts.destroy\n end\n\n scope \"/\" do\n post \"/webhooks/:provider\", glacier.webhooks.receive\n post \"/vote\", Votes.create\n end\nend\n",
),
("src/lib.rs", "let cors = Cors::any_origin();\n"),
(
"src/services/stats.glacier",
"module Stats do\n def total() -> i64 do\n total = 0\n unscoped \"the app-wide note count\" do\n total = Note.count()!\n end\n total\n end\nend\n",
),
(
"src/controllers/books_controller.glacier",
"module BooksController do\n implements Controller\n\n def index do\n books = Book.order_raw(¶ms[:sort]).all()!\n fixed = Book.order_raw(\"pages DESC\").all()!\n end\nend\n",
),
]);
let kinds: Vec<(&str, usize, &str)> = audit(&root)
.iter()
.map(|f| (f.kind, f.line, ""))
.map(|(k, l, _)| (k, l, ""))
.collect();
let found =
|kind: &str, line: usize| kinds.iter().any(|(k, l, _)| *k == kind && *l == line);
assert!(found("castable-key", 5), "{kinds:?}");
assert!(found("sql-string", 6), "{kinds:?}");
assert!(found("sql-string", 7), "{kinds:?}");
assert!(found("cache-key", 5), "{kinds:?}");
assert!(!found("cache-key", 6), "{kinds:?}");
assert!(found("raw-html", 1), "{kinds:?}");
assert!(found("get-changes", 5), "{kinds:?}");
assert!(found("no-csrf", 10), "{kinds:?}");
assert!(found("cors-any", 1), "{kinds:?}");
assert!(!found("no-csrf", 9), "{kinds:?}");
assert!(found("unscoped", 4), "{kinds:?}");
assert!(found("sql-string", 5), "{kinds:?}");
assert_eq!(kinds.len(), 10, "{kinds:?}");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_tidy_app_has_none() {
let root = app(&[
(
"src/routes.glacier",
"routes do\n pipeline browser = Pipeline.browser()\n\n scope \"/\", through: :browser do\n resources \"/posts\", Posts\n post \"/posts/:id/publish\", Posts.publish\n end\nend\n",
),
("src/views/posts/show.html", "<p>{{ post.body }}</p>\n"),
(
"src/models/note.glacier",
"module Note do\n implements Model\n belongs_to :user\nend\n",
),
(
"src/controllers/notes_controller.glacier",
"module NotesController do\n implements Controller\n\n def create do\n note = Note.build(params)\n note.user_id = 1\n end\n\n def update do\n note = Note.find(params[:id])!\n note.update(params.without(\"user_id\"))!\n end\nend\n",
),
]);
assert_eq!(audit(&root), Vec::new());
let _ = fs::remove_dir_all(&root);
}
}