diff --git a/crates/aegis-core/src/ast/imports.rs b/crates/aegis-core/src/ast/imports.rs index 56aad40..7287b05 100644 --- a/crates/aegis-core/src/ast/imports.rs +++ b/crates/aegis-core/src/ast/imports.rs @@ -29,6 +29,14 @@ pub struct Import { pub module: String, /// 1-indexed line where the import appears. pub line: usize, + /// Local alias under which the module is referred to in code, if + /// the language supports renaming on import. Today only populated + /// for Go's `import myrand "math/rand"` form (the import_spec + /// node's `name` field). Other languages' aliasing — Python + /// `import X as Y`, TS `import * as Y from 'X'`, Rust `use X as Y` + /// — return `None` here for now; can be added when a rule + /// concretely needs them. + pub alias: Option, } /// Run the language adapter's `import_query` over the parse tree and @@ -57,7 +65,23 @@ pub fn extract_imports(parsed: &ParsedFile<'_>) -> Vec { continue; } seen.push(key); - out.push(Import { module, line }); + // Best-effort alias detection. Today only Go's + // `import myrand "math/rand"` populates a `name` field on + // the import_spec parent. Other languages parse to + // different shapes; guard with a node-kind check. + let alias = cap + .node + .parent() + .filter(|p| p.kind() == "import_spec") + .and_then(|spec| spec.child_by_field_name("name")) + .and_then(|n| n.utf8_text(src).ok()) + .map(|s| s.to_string()) + .filter(|s| s != "_" && s != "."); + out.push(Import { + module, + line, + alias, + }); } } out @@ -119,4 +143,38 @@ mod tests { let os_import = imports.iter().find(|i| i.module == "os").unwrap(); assert_eq!(os_import.line, 2); } + + #[test] + fn go_aliased_import_captures_alias() { + let src = "package main\n\nimport myrand \"math/rand\"\n"; + let pf = parse("main.go", src).unwrap(); + let imps = extract_imports(&pf); + let mr = imps.iter().find(|i| i.module == "math/rand").unwrap(); + assert_eq!(mr.alias.as_deref(), Some("myrand")); + } + + #[test] + fn go_unaliased_import_has_no_alias() { + let src = "package main\n\nimport \"math/rand\"\n"; + let pf = parse("main.go", src).unwrap(); + let imps = extract_imports(&pf); + let mr = imps.iter().find(|i| i.module == "math/rand").unwrap(); + assert_eq!(mr.alias, None); + } + + #[test] + fn go_blank_and_dot_imports_have_no_alias() { + // `import _ "side/effect"` and `import . "x"` — `_` and `.` + // are not legitimate receivers; alias detection filters them + // out so resolve_receiver doesn't try to match them. + let src = "package main\n\nimport (\n\ + \t_ \"side/effect\"\n\ + \t. \"x\"\n\ + )\n"; + let pf = parse("main.go", src).unwrap(); + let imps = extract_imports(&pf); + for imp in &imps { + assert!(imp.alias.is_none(), "blank/dot import got alias: {imp:?}"); + } + } } diff --git a/crates/aegis-core/src/ast/parsed_file.rs b/crates/aegis-core/src/ast/parsed_file.rs index 7728e44..6e93797 100644 --- a/crates/aegis-core/src/ast/parsed_file.rs +++ b/crates/aegis-core/src/ast/parsed_file.rs @@ -119,7 +119,23 @@ impl<'src> ParsedFile<'src> { if receiver.is_empty() { return None; } + // Pass 1: explicit alias match (Go `import myrand "math/rand"`). + // Aliases override last-segment resolution — `myrand.Intn` must + // never get attributed to the wrong module just because some + // other import happens to end in "myrand". for imp in self.imports() { + if imp.alias.as_deref() == Some(receiver) { + return Some(imp); + } + } + // Pass 2: receiver matches the module path or its last segment. + // Aliased imports skipped here — if the user wrote + // `import myrand "math/rand"`, they don't refer to it as + // `rand`, only as `myrand`. + for imp in self.imports() { + if imp.alias.is_some() { + continue; + } if imp.module == receiver { return Some(imp); } @@ -251,4 +267,35 @@ mod tests { let pf = parse("foo.py", "import os\n").unwrap(); assert!(pf.resolve_receiver("").is_none()); } + + #[test] + fn resolve_receiver_prefers_alias_over_last_segment() { + // Go renamed import: `myrand` should resolve to math/rand + // (alias match), not to nothing. + let src = "package main\n\nimport myrand \"math/rand\"\n\nfunc f() { myrand.Intn(10) }\n"; + let pf = parse("main.go", src).unwrap(); + let resolved = pf.resolve_receiver("myrand").expect("alias resolves"); + assert_eq!(resolved.module, "math/rand"); + assert_eq!(resolved.alias.as_deref(), Some("myrand")); + // The plain `rand` last-segment must NOT resolve, because the + // import was aliased — code under that file refers to it as + // `myrand`, not `rand`. + assert!(pf.resolve_receiver("rand").is_none()); + } + + #[test] + fn resolve_receiver_aliased_and_unaliased_coexist() { + // Mix in the same file: aliased crypto/rand + unaliased + // math/rand. Aliased name `crand` resolves only to crypto/rand; + // bare `rand` resolves to math/rand. + let src = "package main\n\nimport (\n\ + \tcrand \"crypto/rand\"\n\ + \t\"math/rand\"\n\ + )\n"; + let pf = parse("main.go", src).unwrap(); + let crand = pf.resolve_receiver("crand").expect("alias resolves"); + assert_eq!(crand.module, "crypto/rand"); + let rand = pf.resolve_receiver("rand").expect("last-segment resolves"); + assert_eq!(rand.module, "math/rand"); + } } diff --git a/crates/aegis-core/src/security.rs b/crates/aegis-core/src/security.rs index 87eca44..89e7c87 100644 --- a/crates/aegis-core/src/security.rs +++ b/crates/aegis-core/src/security.rs @@ -2011,6 +2011,39 @@ mod tests { assert!(v.iter().any(|v| v.rule_id == "SEC010"), "got {v:?}"); } + #[test] + fn sec010_go_aliased_math_rand_blocks() { + // `import myrand "math/rand"` renames math/rand → myrand. + // SEC010's Go matcher resolves the receiver via Layer 1 + // imports, so myrand.Intn must still be flagged. + let v = check( + ".go", + "package main\n\n\ + import myrand \"math/rand\"\n\n\ + func makeToken() int {\n \ + token := myrand.Intn(1000000)\n \ + return token\n\ + }\n", + ); + assert!(v.iter().any(|v| v.rule_id == "SEC010"), "got {v:?}"); + } + + #[test] + fn sec010_go_aliased_crypto_rand_does_not_block() { + // `import crand "crypto/rand"` — crand is the safe one. + let v = check( + ".go", + "package main\n\n\ + import crand \"crypto/rand\"\n\n\ + func makeToken() []byte {\n \ + token := make([]byte, 16)\n \ + _, _ = crand.Read(token)\n \ + return token\n\ + }\n", + ); + assert!(!v.iter().any(|v| v.rule_id == "SEC010"), "got {v:?}"); + } + #[test] fn sec010_go_crypto_rand_read_does_not_block() { // Same `rand.Read` literal text, but `import "crypto/rand"`.