mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
Normalize native relative paths before slash-based CSP classification and signal output. Preserve literal Unix backslashes. AI assistance: Codex, under maintainer direction.
283 lines
11 KiB
Rust
283 lines
11 KiB
Rust
//! JS: detect-csp.mjs -> `impeccable detect-csp`
|
|
|
|
use crate::jsp;
|
|
use crate::util::{exists, json_pretty, read_dir_entries};
|
|
use impeccable_common::Io;
|
|
use once_cell::sync::Lazy;
|
|
use regex::Regex;
|
|
use serde_json::{Map, Value};
|
|
|
|
const SKIP_DIRS: [&str; 11] =
|
|
["node_modules", ".git", ".next", ".turbo", ".svelte-kit", ".nuxt", ".astro", "dist", "build", "out", ".vercel"];
|
|
const SCAN_EXTS: [&str; 8] = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts", ".tsx", ".jsx"];
|
|
const LAYOUT_EXTS: [&str; 6] = [".tsx", ".jsx", ".astro", ".vue", ".svelte", ".html"];
|
|
const MAX_DEPTH: usize = 6;
|
|
const MAX_READ_BYTES: usize = 64 * 1024;
|
|
|
|
static MONOREPO_HELPER: Lazy<Vec<Regex>> = Lazy::new(|| {
|
|
["buildCSPConfig", "buildSecurityHeaders", "additionalScriptSrc", "additionalConnectSrc", "createBaseNextConfig"]
|
|
.iter()
|
|
.map(|w| Regex::new(&format!(r"(?-u:\b){}(?-u:\b)", w)).unwrap())
|
|
.collect()
|
|
});
|
|
static SVELTEKIT: Lazy<Vec<Regex>> = Lazy::new(|| {
|
|
[r"(?-u:\b)kit\s*:", r"(?-u:\b)csp\s*:", r"(?-u:\b)directives\s*:"].iter().map(|r| Regex::new(r).unwrap()).collect()
|
|
});
|
|
static NUXT_SECURITY: Lazy<Vec<Regex>> = Lazy::new(|| {
|
|
[r#"['"]nuxt-security['"]"#, r"(?-u:\b)contentSecurityPolicy(?-u:\b)"].iter().map(|r| Regex::new(r).unwrap()).collect()
|
|
});
|
|
static INLINE_HEADER: Lazy<Vec<Regex>> = Lazy::new(|| {
|
|
[r#"(?i)["']Content-Security-Policy["']"#, r"(?-u:\b)script-src(?-u:\b)", r"(?-u:\b)connect-src(?-u:\b)"]
|
|
.iter()
|
|
.map(|r| Regex::new(r).unwrap())
|
|
.collect()
|
|
});
|
|
static MIDDLEWARE_HINT: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?i)headers\.set\(\s*["']Content-Security-Policy["']"#).unwrap());
|
|
static META_TAG_HINT: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?i)http-equiv\s*=\s*["']Content-Security-Policy["']"#).unwrap());
|
|
static MONOREPO_PATH: Lazy<Regex> = Lazy::new(|| Regex::new(r"packages/[^/]+/src/.*(config|next-config|security)").unwrap());
|
|
static CONFIG_PATH: Lazy<Regex> = Lazy::new(|| Regex::new(r"(^|/)(next|nuxt|vite|astro|svelte)\.config\.").unwrap());
|
|
|
|
struct Hits {
|
|
append_arrays: Vec<String>,
|
|
append_string: Vec<String>,
|
|
middleware: Vec<String>,
|
|
meta_tag: Vec<String>,
|
|
}
|
|
|
|
fn is_config(rel: &str, name: &str) -> bool {
|
|
Regex::new(&format!(r"(^|/){}\.config\.", name)).map(|r| r.is_match(rel)).unwrap_or(false)
|
|
}
|
|
|
|
const NEXT_MIDDLEWARE_FILES: &[&str] = &["middleware.ts", "middleware.js", "middleware.mjs"];
|
|
const NEXT_PROXY_FILES: &[&str] = &["proxy.ts", "proxy.js", "proxy.mjs"];
|
|
const NEXT_CONFIG_FILES: &[&str] = &[
|
|
"next.config.js",
|
|
"next.config.mjs",
|
|
"next.config.cjs",
|
|
"next.config.ts",
|
|
"next.config.mts",
|
|
"next.config.cts",
|
|
];
|
|
|
|
/// JS: detect-csp.mjs#hasNextProjectMarker
|
|
fn has_next_project_marker(project_root: &str) -> bool {
|
|
if NEXT_CONFIG_FILES.iter().any(|n| exists(&jsp::join(&[project_root, n]))) {
|
|
return true;
|
|
}
|
|
if ["app", "pages", "src/app", "src/pages"]
|
|
.iter()
|
|
.any(|rel| exists(&jsp::join(&[project_root, rel])))
|
|
{
|
|
return true;
|
|
}
|
|
let Ok(raw) = std::fs::read_to_string(jsp::join(&[project_root, "package.json"])) else {
|
|
return false;
|
|
};
|
|
let Ok(pkg) = serde_json::from_str::<Value>(&raw) else {
|
|
return false;
|
|
};
|
|
["dependencies", "devDependencies", "peerDependencies"]
|
|
.iter()
|
|
.any(|group| {
|
|
pkg.get(*group)
|
|
.and_then(|g| g.as_object())
|
|
.is_some_and(|g| g.contains_key("next"))
|
|
})
|
|
}
|
|
|
|
/// JS: detect-csp.mjs#isNextRequestHookFile
|
|
///
|
|
/// Next.js 16 recognizes `proxy` at the project root or in the optional `src/`
|
|
/// directory, alongside `app/` or `pages/`. The scan root is commonly a
|
|
/// monorepo, so that placement is also accepted relative to a nested directory
|
|
/// carrying a concrete Next.js project marker. A same-named helper elsewhere in
|
|
/// the tree is not the framework request hook.
|
|
fn is_next_request_hook_file(root: &str, abs_path: &str, rel_path: &str, base: &str) -> bool {
|
|
if NEXT_MIDDLEWARE_FILES.contains(&base) {
|
|
return true;
|
|
}
|
|
if !NEXT_PROXY_FILES.contains(&base) {
|
|
return false;
|
|
}
|
|
let normalized = jsp::to_posix(rel_path).to_lowercase();
|
|
if normalized == base || normalized == format!("src/{base}") {
|
|
return true;
|
|
}
|
|
let hook_dir = jsp::dirname(abs_path);
|
|
let project_root = if jsp::basename(&hook_dir).to_lowercase() == "src" {
|
|
jsp::dirname(&hook_dir)
|
|
} else {
|
|
hook_dir
|
|
};
|
|
if jsp::resolve(&project_root, &[]) == jsp::resolve(root, &[]) {
|
|
return true;
|
|
}
|
|
has_next_project_marker(&project_root)
|
|
}
|
|
|
|
fn visit(root: &str, abs: &str, rel: &str, body: &str, hits: &mut Hits) {
|
|
let ext = jsp::extname(abs);
|
|
let base = jsp::basename(abs).to_lowercase();
|
|
let scan = SCAN_EXTS.contains(&ext.as_str());
|
|
if scan && MONOREPO_PATH.is_match(rel) && MONOREPO_HELPER.iter().any(|r| r.is_match(body)) {
|
|
hits.append_arrays.push(rel.to_string());
|
|
return;
|
|
}
|
|
if scan && is_config(rel, "svelte") && SVELTEKIT.iter().all(|r| r.is_match(body)) {
|
|
hits.append_arrays.push(rel.to_string());
|
|
return;
|
|
}
|
|
if scan && is_config(rel, "nuxt") && NUXT_SECURITY.iter().all(|r| r.is_match(body)) {
|
|
hits.append_arrays.push(rel.to_string());
|
|
return;
|
|
}
|
|
if scan && CONFIG_PATH.is_match(rel) && INLINE_HEADER.iter().all(|r| r.is_match(body)) {
|
|
hits.append_string.push(rel.to_string());
|
|
return;
|
|
}
|
|
if is_next_request_hook_file(root, abs, rel, &base) && MIDDLEWARE_HINT.is_match(body) {
|
|
hits.middleware.push(rel.to_string());
|
|
}
|
|
if LAYOUT_EXTS.contains(&ext.as_str()) && META_TAG_HINT.is_match(body) {
|
|
hits.meta_tag.push(rel.to_string());
|
|
}
|
|
}
|
|
|
|
fn walk(root: &str, dir: &str, depth: usize, hits: &mut Hits) {
|
|
if depth > MAX_DEPTH {
|
|
return;
|
|
}
|
|
let Some(entries) = read_dir_entries(dir) else { return };
|
|
for e in entries {
|
|
let abs = jsp::join(&[dir, &e.name]);
|
|
if e.is_dir {
|
|
if SKIP_DIRS.contains(&e.name.as_str()) {
|
|
continue;
|
|
}
|
|
walk(root, &abs, depth + 1, hits);
|
|
continue;
|
|
}
|
|
if !e.is_file {
|
|
continue;
|
|
}
|
|
let ext = jsp::extname(&e.name);
|
|
if !SCAN_EXTS.contains(&ext.as_str()) && !LAYOUT_EXTS.contains(&ext.as_str()) {
|
|
continue;
|
|
}
|
|
let Ok(bytes) = std::fs::read(&abs) else { continue };
|
|
let slice = if bytes.len() > MAX_READ_BYTES { &bytes[..MAX_READ_BYTES] } else { &bytes[..] };
|
|
let body = String::from_utf8_lossy(slice);
|
|
// Candidate patterns use '/', while native Windows relative paths
|
|
// use '\\'. Normalize once for classification and portable signals;
|
|
// to_posix preserves literal backslashes in Unix filenames.
|
|
let rel = jsp::to_posix(&jsp::relative("/", root, &abs));
|
|
visit(root, &abs, &rel, &body, hits);
|
|
}
|
|
}
|
|
|
|
pub fn detect_csp(cwd: &str) -> Value {
|
|
let mut hits = Hits { append_arrays: vec![], append_string: vec![], middleware: vec![], meta_tag: vec![] };
|
|
walk(cwd, cwd, 0, &mut hits);
|
|
let (shape, signals): (Option<&str>, Vec<String>) = if !hits.append_arrays.is_empty() {
|
|
(Some("append-arrays"), hits.append_arrays)
|
|
} else if !hits.append_string.is_empty() {
|
|
(Some("append-string"), hits.append_string)
|
|
} else if !hits.middleware.is_empty() {
|
|
(Some("middleware"), hits.middleware)
|
|
} else if !hits.meta_tag.is_empty() {
|
|
(Some("meta-tag"), hits.meta_tag)
|
|
} else {
|
|
(None, vec![])
|
|
};
|
|
let mut m = Map::new();
|
|
m.insert("shape".into(), shape.map(|s| Value::String(s.to_string())).unwrap_or(Value::Null));
|
|
m.insert("signals".into(), Value::Array(signals.into_iter().map(Value::String).collect()));
|
|
Value::Object(m)
|
|
}
|
|
|
|
pub fn run(_args: &[String], io: &mut Io) -> i32 {
|
|
let cwd = io.cwd.to_string_lossy().into_owned();
|
|
let v = detect_csp(&cwd);
|
|
io.out(&format!("{}\n", json_pretty(&v)));
|
|
0
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
static NEXT_FIXTURE: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
struct Fixture(PathBuf);
|
|
|
|
impl Fixture {
|
|
fn new() -> Self {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"impeccable-csp-761-{}-{}",
|
|
std::process::id(), NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed),
|
|
));
|
|
std::fs::create_dir(&root).unwrap();
|
|
Self(root)
|
|
}
|
|
|
|
fn scan(&self, path: &str, body: &str) -> Value {
|
|
let file = self.0.join(path);
|
|
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
|
std::fs::write(file, body).unwrap();
|
|
detect_csp(self.0.to_str().unwrap())
|
|
}
|
|
}
|
|
|
|
impl Drop for Fixture {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn nested_csp_candidates_use_portable_paths() {
|
|
// Exercise the filesystem walker and native path.relative semantics,
|
|
// not just regexes with pre-normalized input. Windows CI reproduces #761.
|
|
for (path, body, shape) in [
|
|
("packages/app/src/security/csp.ts", "buildCSPConfig()", "append-arrays"),
|
|
("packages/app/src/next-config.ts", "createBaseNextConfig()", "append-arrays"),
|
|
("apps/web/svelte.config.js", "kit: { csp: { directives: {} } }", "append-arrays"),
|
|
("apps/web/nuxt.config.ts", "'nuxt-security'; contentSecurityPolicy", "append-arrays"),
|
|
("apps/web/next.config.mjs", "'Content-Security-Policy': 'script-src self; connect-src self'", "append-string"),
|
|
("next.config.mjs", "'Content-Security-Policy': 'script-src self; connect-src self'", "append-string"),
|
|
("apps/web/src/middleware.ts", "headers.set('Content-Security-Policy', policy)", "middleware"),
|
|
("apps/web/src/layout.astro", "<meta http-equiv='Content-Security-Policy'>", "meta-tag"),
|
|
] {
|
|
assert_eq!(Fixture::new().scan(path, body), serde_json::json!({
|
|
"shape": shape, "signals": [path],
|
|
}), "{path}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unrelated_nested_files_are_not_csp_candidates() {
|
|
for (path, body) in [
|
|
("packages/app/src/utils/csp.ts", "buildCSPConfig()"),
|
|
("apps/web/not-svelte.config.js", "kit: { csp: { directives: {} } }"),
|
|
("apps/web/next.config.mjs", "'Content-Security-Policy': 'script-src self'"),
|
|
] {
|
|
assert_eq!(Fixture::new().scan(path, body), serde_json::json!({
|
|
"shape": null, "signals": [],
|
|
}), "{path}");
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn posix_backslashes_remain_literal_filename_characters() {
|
|
let result = Fixture::new().scan(
|
|
"packages/app/src/config\\notes.ts", "buildCSPConfig()",
|
|
);
|
|
assert_eq!(result, serde_json::json!({
|
|
"shape": "append-arrays", "signals": ["packages/app/src/config\\notes.ts"],
|
|
}));
|
|
}
|
|
}
|