// audit/mod.rs --- T M7.9 audit lint engine. //! Audit-lint engine (T M7.9, spec §sec:packages-future). //! //! Static analyzer for Lua package source. Parses with //! [`tree_sitter_lua`], runs the queries declared in //! `audit/audit-rules.scm` against the resulting AST, and emits //! [`AuditFinding`]s naming the file, line, rule, severity, and //! offending snippet. The engine never executes audited code, so //! malicious top-level Lua cannot side-effect the lint host. //! //! ## Why tree-sitter //! //! Three alternatives were considered (see `TRANSITION-M7.md` T M7.9 //! section): `luaparse` (Node), a custom mlua-based analyzer //! (loads-and-runs untrusted code), and `full-moon` (Rust AST, //! programmatic rules). Tree-sitter queries win on three axes: //! pure-Rust (no JS runtime), no execution of audited code, and //! declarative -- the rule set in `audit/audit-rules.scm` is //! reviewable by non-Rust readers and proposable by external //! authors. `full-moon` remains an option if a future rule needs //! cross-statement data flow. //! //! ## Rule set //! //! The v1.0 rules are catalogued in [`rules::DEFAULT_RULES`] and //! correspond by index to the patterns in //! [`AUDIT_QUERY_SOURCE`]. The engine surface is: //! //! - [`AuditEngine::new`] -- compiles the queries once. //! - [`AuditEngine::audit_source`] -- audits a string of Lua, the //! primitive used by every other entry point. //! - [`AuditEngine::audit_file`] -- reads a path and audits. //! - [`AuditEngine::audit_dir`] -- recursively audits every `*.lua` //! under a directory. //! //! ## Output //! //! [`AuditFinding`] derives [`serde::Serialize`]; the CLI binary //! `pmacs-audit` writes a JSON [`AuditReport`] of all findings. //! Schema: //! //! ```text //! { //! "findings": [ //! { //! "file": "path/to/x.lua", //! "line": 42, // 1-based //! "column": 11, // 1-based //! "rule": "no-ffi-call", //! "severity": "error" | "warning" | "info", //! "message": "ffi.cdef / ffi.load / ffi.metatype escape the Lua sandbox", //! "snippet": "ffi.cdef[[ int x; ]]" //! }, ... //! ], //! "summary": { "errors": N, "warnings": N, "infos": N } //! } //! ``` use std::fs; use std::io; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use thiserror::Error; use tree_sitter::{Parser, Query, QueryCursor, StreamingIterator}; pub mod rules; pub use rules::{AuditRule, DEFAULT_RULES, Severity}; /// Source text of the rule queries. Compile-time-included so the /// binary has no runtime dependency on the source tree; the file /// itself remains the published contract for external authors who /// want to read or extend the rules. pub const AUDIT_QUERY_SOURCE: &str = include_str!("../../audit/audit-rules.scm"); /// One audit-lint hit. Stable JSON shape; v1.0 consumers can rely /// on field names and `severity` enum variants persisting. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuditFinding { /// File path the finding was produced from. Paths are stored as /// the caller passed them in: relative paths stay relative. pub file: PathBuf, /// 1-based line number of the violating node's first character. pub line: usize, /// 1-based column number of the violating node's first character. pub column: usize, /// Rule identifier (e.g., `"no-ffi-call"`). pub rule: String, /// Severity level. pub severity: Severity, /// Human-readable rule description. pub message: String, /// The matched source slice, preserved verbatim. Newlines are /// kept; consumers that render to a single line should /// `replace('\n', " ")` themselves. pub snippet: String, } /// Aggregate report. The CLI binary writes one of these to stdout /// as JSON. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuditReport { /// Per-finding records, in source order across files. pub findings: Vec, /// Counts grouped by severity. Mirrors `findings` --- the /// summary is redundant but cheap, and CI integrations /// frequently want a single number to gate on. pub summary: AuditSummary, } impl AuditReport { /// Build a report from a finding list, populating `summary`. #[must_use] pub fn new(findings: Vec) -> Self { let mut summary = AuditSummary::default(); for f in &findings { match f.severity { Severity::Error => summary.errors += 1, Severity::Warning => summary.warnings += 1, Severity::Info => summary.infos += 1, } } Self { findings, summary } } } /// Severity counts for an [`AuditReport`]. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct AuditSummary { /// Number of [`Severity::Error`] findings in the report. pub errors: usize, /// Number of [`Severity::Warning`] findings. pub warnings: usize, /// Number of [`Severity::Info`] findings. pub infos: usize, } /// Errors raised during audit setup or scan I/O. Per-file parse /// failures do not raise --- they emit a special finding so a /// single broken file in a tree doesn't abort the whole audit. #[derive(Debug, Error)] pub enum AuditError { /// The compiled tree-sitter [`Query`] failed to load. Should /// only happen on a grammar/query ABI skew. #[error("audit query compile failed: {0}")] QueryCompile(String), /// Couldn't set the language on the parser. #[error("audit parser setup failed: {0}")] ParserSetup(String), /// I/O error reading a file or directory. #[error("audit I/O error at {path}: {source}")] Io { /// Path the operation was attempted against. path: PathBuf, /// Underlying error. #[source] source: io::Error, }, } /// Audit engine. Holds the compiled query and a parser; reusable /// across many files. pub struct AuditEngine { query: Query, capture_violation: u32, } impl AuditEngine { /// Compile the v1.0 rule set. Returns an error only on /// grammar/query ABI skew (i.e., a tree-sitter-lua upgrade that /// renames a node). pub fn new() -> Result { let lang: tree_sitter::Language = tree_sitter_lua::LANGUAGE.into(); let query = Query::new(&lang, AUDIT_QUERY_SOURCE) .map_err(|e| AuditError::QueryCompile(format!("{e:?}")))?; let capture_violation = query.capture_index_for_name("violation").ok_or_else(|| { AuditError::QueryCompile("audit-rules.scm missing required `@violation` capture".into()) })?; // Rule table and pattern count must agree by index. if query.pattern_count() != DEFAULT_RULES.len() { return Err(AuditError::QueryCompile(format!( "rule table / query pattern count mismatch: {} vs {}", DEFAULT_RULES.len(), query.pattern_count() ))); } Ok(Self { query, capture_violation, }) } /// Audit a single Lua source string. `path` is recorded in the /// findings unmodified. #[must_use] pub fn audit_source(&self, path: impl Into, src: &str) -> Vec { let path = path.into(); let lang: tree_sitter::Language = tree_sitter_lua::LANGUAGE.into(); let mut parser = Parser::new(); if parser.set_language(&lang).is_err() { return Vec::new(); } let Some(tree) = parser.parse(src.as_bytes(), None) else { return Vec::new(); }; let mut cursor = QueryCursor::new(); let mut matches = cursor.matches(&self.query, tree.root_node(), src.as_bytes()); let mut out = Vec::new(); while let Some(m) = matches.next() { let pat = m.pattern_index; // Tree-sitter guarantees pattern_index < pattern_count // (we asserted the table size in `new`), so an out-of- // range index is a tree-sitter bug we won't paper over. let rule = DEFAULT_RULES[pat]; for cap in m.captures { if cap.index == self.capture_violation { let node = cap.node; let start = node.start_position(); let snippet = src .as_bytes() .get(node.byte_range()) .and_then(|b| std::str::from_utf8(b).ok()) .unwrap_or("") .to_string(); out.push(AuditFinding { file: path.clone(), line: start.row + 1, column: start.column + 1, rule: rule.name.to_string(), severity: rule.severity, message: rule.message.to_string(), snippet, }); } } } // Stable order: by (file, line, column, rule). out.sort_by(|a, b| { a.file .cmp(&b.file) .then(a.line.cmp(&b.line)) .then(a.column.cmp(&b.column)) .then(a.rule.cmp(&b.rule)) }); out } /// Audit a single file at `path`. Reads the file then delegates /// to [`Self::audit_source`]. pub fn audit_file(&self, path: &Path) -> Result, AuditError> { let src = fs::read_to_string(path).map_err(|e| AuditError::Io { path: path.to_path_buf(), source: e, })?; Ok(self.audit_source(path.to_path_buf(), &src)) } /// Audit every `*.lua` file beneath `dir`. Symlinks are /// followed once (the standard-library `read_dir` policy). The /// returned findings are concatenated across files in /// alphabetical-path order. pub fn audit_dir(&self, dir: &Path) -> Result, AuditError> { let mut files = collect_lua_files(dir)?; files.sort(); let mut all = Vec::new(); for file in files { all.extend(self.audit_file(&file)?); } Ok(all) } } fn collect_lua_files(dir: &Path) -> Result, AuditError> { let mut out = Vec::new(); let mut stack = vec![dir.to_path_buf()]; while let Some(d) = stack.pop() { let entries = fs::read_dir(&d).map_err(|e| AuditError::Io { path: d.clone(), source: e, })?; for entry in entries { let entry = entry.map_err(|e| AuditError::Io { path: d.clone(), source: e, })?; let p = entry.path(); let ft = entry.file_type().map_err(|e| AuditError::Io { path: p.clone(), source: e, })?; if ft.is_dir() { stack.push(p); } else if ft.is_file() && p.extension().is_some_and(|e| e == "lua") { out.push(p); } } } Ok(out) } #[cfg(test)] mod tests { use super::*; fn engine() -> AuditEngine { AuditEngine::new().expect("rule set compiles against bundled tree-sitter-lua") } #[test] fn rules_table_aligns_with_query_pattern_count() { // Constructor asserts this; calling `new` is enough to fail // a misalignment. The explicit check here makes the failure // mode obvious if the constructor's contract changes. let e = engine(); assert_eq!(e.query.pattern_count(), DEFAULT_RULES.len()); } #[test] fn detects_private_surface_require() { let f = engine().audit_source("t.lua", r#"local m = require("pmacs._internal.foo")"#); assert_eq!(f.len(), 1, "expected exactly one finding, got {f:?}"); assert_eq!(f[0].rule, "no-private-surface-require"); assert_eq!(f[0].severity, Severity::Error); } #[test] fn detects_private_surface_core_namespace() { let f = engine().audit_source("t.lua", r#"local m = require("pmacs.core.bar")"#); assert!(f.iter().any(|x| x.rule == "no-private-surface-require")); } #[test] fn detects_private_surface_identifier() { let f = engine().audit_source("t.lua", "local _pmacs_internal_q = 1"); assert!( f.iter().any(|x| x.rule == "no-private-surface-identifier"), "got {f:?}" ); } #[test] fn detects_ffi_calls() { for src in [ "ffi.cdef[[ int x; ]]", "ffi.load(\"c\")", "ffi.metatype(t, mt)", ] { let f = engine().audit_source("t.lua", src); assert!( f.iter().any(|x| x.rule == "no-ffi-call"), "{src} produced {f:?}" ); } } #[test] fn detects_package_loadlib_and_cpath_mutation() { let src = r#" package.loadlib("/lib/x.so", "init") package.cpath = "/extra/?.so" "#; let f = engine().audit_source("t.lua", src); assert!(f.iter().any(|x| x.rule == "no-package-loadlib")); assert!(f.iter().any(|x| x.rule == "no-package-cpath-mutation")); } #[test] fn package_cpath_read_is_not_a_finding() { // Rule 4 only fires on the LHS of an assignment; reading // `package.cpath` is allowed (and harmless). let f = engine().audit_source("t.lua", "local p = package.cpath"); assert!(f.iter().all(|x| x.rule != "no-package-cpath-mutation")); } #[test] fn detects_debug_sethook_and_setmetatable() { let src = r#" debug.sethook(myhook, "c") debug.setmetatable(t, mt) "#; let f = engine().audit_source("t.lua", src); assert!(f.iter().any(|x| x.rule == "no-debug-sethook")); assert!(f.iter().any(|x| x.rule == "no-debug-setmetatable")); } #[test] fn detects_rawget_rawset_on_globals() { let src = r#" rawget(_G, "x") rawset(_G, "y", 1) "#; let f = engine().audit_source("t.lua", src); let count = f .iter() .filter(|x| x.rule == "no-rawget-rawset-on-globals") .count(); assert_eq!(count, 2, "got {f:?}"); } #[test] fn rawget_on_a_local_table_is_not_a_finding() { let f = engine().audit_source("t.lua", "rawget(my_table, \"k\")"); assert!(f.iter().all(|x| x.rule != "no-rawget-rawset-on-globals")); } #[test] fn detects_setfenv_and_getfenv() { let src = r" setfenv(2, env) getfenv(0) "; let f = engine().audit_source("t.lua", src); assert!(f.iter().filter(|x| x.rule == "no-setfenv-getfenv").count() == 2); } #[test] fn detects_io_open_with_write_mode() { let f = engine().audit_source("t.lua", r#"io.open("/tmp/x", "w")"#); assert!(f.iter().any(|x| x.rule == "no-fs-mutation-io-open-write")); } #[test] fn io_open_with_read_mode_is_not_a_finding() { let f = engine().audit_source("t.lua", r#"io.open("/tmp/x", "r")"#); assert!(f.iter().all(|x| x.rule != "no-fs-mutation-io-open-write")); } #[test] fn io_open_without_mode_is_not_a_finding() { // Mode defaults to "r" per the Lua reference manual. let f = engine().audit_source("t.lua", r#"io.open("/tmp/x")"#); assert!(f.iter().all(|x| x.rule != "no-fs-mutation-io-open-write")); } #[test] fn detects_os_remove_and_rename() { let src = r#" os.remove("/tmp/y") os.rename("a", "b") "#; let f = engine().audit_source("t.lua", src); assert_eq!( f.iter().filter(|x| x.rule == "no-fs-mutation-os").count(), 2 ); } #[test] fn detects_io_popen_os_execute_and_pmacs_process_spawn() { let src = r#" io.popen("ls") os.execute("rm -rf /") pmacs.process.spawn("ls") "#; let f = engine().audit_source("t.lua", src); assert!(f.iter().any(|x| x.rule == "no-process-spawn-io")); assert!(f.iter().any(|x| x.rule == "no-process-spawn-os")); assert!(f.iter().any(|x| x.rule == "no-process-spawn-pmacs")); } #[test] fn reach_around_dotted_require_is_info_level() { let f = engine().audit_source("t.lua", r#"local x = require("otherpkg.private")"#); let r = f .iter() .find(|x| x.rule == "reach-around-require") .expect("expected reach-around finding"); assert_eq!(r.severity, Severity::Info); } #[test] fn reach_around_does_not_fire_on_pmacs_namespace() { // Rules 0 and 1 cover pmacs.* private surface; the // reach-around rule explicitly excludes the `pmacs` prefix // so a single bad require doesn't double-report. let f = engine().audit_source("t.lua", r#"require("pmacs._internal.foo")"#); assert!(f.iter().all(|x| x.rule != "reach-around-require")); } #[test] fn reach_around_field_access_is_info_level() { let f = engine().audit_source( "t.lua", r#"local seam = require("otherpkg").__pmacs_outline_test_seam_DO_NOT_USE"#, ); let r = f .iter() .find(|x| x.rule == "reach-around-require-field") .expect("expected reach-around field finding"); assert_eq!(r.severity, Severity::Info); } #[test] fn reach_around_field_access_ignores_public_and_pmacs_fields() { let f = engine().audit_source( "t.lua", r#" local ok = require("otherpkg").query local host = require("pmacs.foo")._private "#, ); assert!( f.iter().all(|x| x.rule != "reach-around-require-field"), "expected no field reach-around findings, got {f:?}" ); } #[test] fn bare_require_is_not_a_finding() { let f = engine().audit_source("t.lua", r#"require("magit")"#); assert!(f.is_empty(), "expected no findings, got {f:?}"); } #[test] fn finding_records_line_and_column() { // Multi-line snippet so the line number must be > 1. let src = "-- header\n-- comment\nffi.load(\"c\")\n"; let f = engine().audit_source("t.lua", src); let r = f .iter() .find(|x| x.rule == "no-ffi-call") .expect("expected ffi finding"); assert_eq!(r.line, 3); assert_eq!(r.column, 1); assert_eq!(r.snippet, "ffi.load(\"c\")"); } #[test] fn audit_report_summary_counts_match_findings() { let f = engine().audit_source( "t.lua", r#" ffi.cdef[[ int x; ]] io.popen("ls") require("otherpkg.x") "#, ); let r = AuditReport::new(f); assert_eq!(r.summary.errors, 1); assert_eq!(r.summary.warnings, 1); assert_eq!(r.summary.infos, 1); } #[test] fn empty_file_yields_no_findings() { let f = engine().audit_source("t.lua", ""); assert!(f.is_empty()); } #[test] fn audit_source_is_stable_order_within_file() { let src = "ffi.cdef\"\"\nffi.load\"\"\nio.popen\"\"\n"; let f = engine().audit_source("t.lua", src); // line ascending for w in f.windows(2) { assert!( (w[0].line, w[0].column) <= (w[1].line, w[1].column), "findings out of order: {f:?}" ); } } #[test] fn audit_dir_recurses_and_finds_nested_lua() { let dir = tempfile::tempdir().unwrap(); let nested = dir.path().join("a/b"); fs::create_dir_all(&nested).unwrap(); fs::write(dir.path().join("top.lua"), "ffi.cdef\"\"\n").unwrap(); fs::write(nested.join("inner.lua"), "io.popen\"x\"\n").unwrap(); // A non-Lua file should be ignored. fs::write(dir.path().join("readme.txt"), "ffi.cdef should not match\n").unwrap(); let f = engine().audit_dir(dir.path()).unwrap(); let rules: Vec<_> = f.iter().map(|x| x.rule.as_str()).collect(); assert!(rules.contains(&"no-ffi-call")); assert!(rules.contains(&"no-process-spawn-io")); } }