@@ -22,12 +22,34 @@ struct Instruction {
2222}
2323
2424impl super :: Instruction for Instruction {
25+ /// CIL is a stack-based ISA — there is no notion of a "stack
26+ /// variable" in the x86 sense. Locals are addressed by index
27+ /// (`ldloc.N` / `stloc.N`), not by frame offset, and the
28+ /// stack-string heuristic that drives `is_mov_imm_to_stack` on
29+ /// x86 backends doesn't apply. Python capa makes the same call —
30+ /// it doesn't define this method on the dnfile extractor at all.
31+ /// Safe default `false` matches Python's behaviour: the
32+ /// stack-string detector skips .NET methods.
33+ ///
34+ /// 0.4.x: previously `unimplemented!()`. Replaced in 0.5.0 — the
35+ /// panic was a latent footgun for any caller that held a
36+ /// `&dyn Instruction` without knowing which extractor produced it.
2537 fn is_mov_imm_to_stack ( & self ) -> Result < bool > {
26- unimplemented ! ( )
38+ Ok ( false )
2739 }
40+
41+ /// CIL string literals come from the `#US` heap via `ldstr`, not
42+ /// as byte arrays moved to a stack frame, so there are no
43+ /// "printable bytes" to count on an instruction. Python capa
44+ /// doesn't define this for the dnfile extractor. Safe default
45+ /// `0` keeps the stack-string aggregator at 0 contributions for
46+ /// .NET methods — same outcome as Python.
47+ ///
48+ /// 0.4.x: previously `unimplemented!()`.
2849 fn get_printable_len ( & self ) -> Result < u64 > {
29- unimplemented ! ( )
50+ Ok ( 0 )
3051 }
52+
3153 fn as_any ( & self ) -> & dyn std:: any:: Any {
3254 self
3355 }
@@ -36,17 +58,41 @@ impl super::Instruction for Instruction {
3658#[ derive( Debug , Clone ) ]
3759struct Function {
3860 f : cil:: function:: Function ,
61+ /// Python capa convention: `calls_to` = set of CALLERS (incoming
62+ /// references — addresses of methods that call this one).
3963 calls_to : HashSet < u64 > ,
64+ /// Python capa convention: `calls_from` = set of CALLEES (outgoing
65+ /// references — addresses of methods this one calls).
4066 calls_from : HashSet < u64 > ,
67+ /// Materialised caller list for the `inrefs` trait method. Same
68+ /// content as `calls_to`, kept as `Vec` because the trait returns
69+ /// `&Vec<u64>`. Populated at `get_functions` construction.
70+ inrefs : Vec < u64 > ,
71+ /// Per-block successor map for the `blockrefs` trait method. The
72+ /// .NET extractor treats each method as a single basic block
73+ /// (matches `get_blocks` below), so this is always
74+ /// `{first_insn_offset: vec![]}` — one entry, no successors. A
75+ /// future CIL CFG split (br/brtrue/brfalse/switch walking) would
76+ /// expand this; tracked as a follow-up.
77+ blockrefs : HashMap < u64 , Vec < u64 > > ,
4178}
4279
4380impl super :: Function for Function {
81+ /// Callers of this method. 0.4.x: `unimplemented!()`. 0.5.0:
82+ /// populated from `calls_to` at construction.
4483 fn inrefs ( & self ) -> & Vec < u64 > {
45- unimplemented ! ( )
84+ & self . inrefs
4685 }
86+
87+ /// Per-basic-block successor map. 0.4.x: `unimplemented!()`.
88+ /// 0.5.0: returns a single-entry map matching `get_blocks`'
89+ /// single-block-per-method shape. Proper CIL CFG split is a
90+ /// follow-up — it requires walking br/brtrue/brfalse/switch
91+ /// targets and re-segmenting the instruction list.
4792 fn blockrefs ( & self ) -> & HashMap < u64 , Vec < u64 > > {
48- unimplemented ! ( )
93+ & self . blockrefs
4994 }
95+
5096 fn offset ( & self ) -> u64 {
5197 self . f . offset as u64
5298 }
@@ -179,11 +225,27 @@ impl<'data> super::Extractor for Extractor<'data> {
179225 }
180226
181227 fn get_functions ( & self ) -> Result < std:: collections:: BTreeMap < u64 , Box < dyn super :: Function > > > {
182- let mut methods: std:: collections:: HashMap < u64 , Function > =
183- std:: collections:: HashMap :: new ( ) ;
184- let mut calls_to_map = HashMap :: new ( ) ;
228+ // 0.5.0: rebuilt to match Python capa's `calls_to` (callers,
229+ // incoming) / `calls_from` (callees, outgoing) convention.
230+ // Pre-0.5.0 had two bugs that compounded into a silently-wrong
231+ // call graph for every .NET assembly:
232+ // 1. `calls_to` was initialised empty and never populated.
233+ // 2. The reverse-index loop overwrote `f.calls_from` with the
234+ // caller set instead of populating `f.calls_to`, which
235+ // both inverted the `calls from` characteristic feature
236+ // and broke the `recursive call` detector (it checked
237+ // `f.calls_to`, which was always empty).
238+ //
239+ // First pass builds `callers_of[target] = {caller, ...}` and
240+ // records each function's outgoing callee set. Second pass
241+ // assembles the `Function` values with both fields filled in
242+ // the correct direction.
243+ let mut callers_of: HashMap < u64 , HashSet < u64 > > = HashMap :: new ( ) ;
244+ let mut callees_of: HashMap < u64 , HashSet < u64 > > = HashMap :: new ( ) ;
245+
185246 for f in self . pe ( ) . net ( ) ?. functions ( ) {
186- let mut calls_from = HashSet :: new ( ) ;
247+ let caller_addr = f. offset as u64 ;
248+ let mut my_callees: HashSet < u64 > = HashSet :: new ( ) ;
187249 for insn in & f. instructions {
188250 if ![
189251 OpCodeValue :: Call ,
@@ -195,25 +257,40 @@ impl<'data> super::Extractor for Extractor<'data> {
195257 {
196258 continue ;
197259 }
198- let address = insn. operand . value ( ) ?;
199- let ee = calls_to_map. entry ( address as u64 ) . or_insert ( HashSet :: new ( ) ) ;
200- ee. insert ( f. offset as u64 ) ;
201- calls_from. insert ( address as u64 ) ;
260+ let target = insn. operand . value ( ) ? as u64 ;
261+ my_callees. insert ( target) ;
262+ callers_of. entry ( target) . or_default ( ) . insert ( caller_addr) ;
263+ }
264+ callees_of. insert ( caller_addr, my_callees) ;
265+ }
266+
267+ let mut methods: HashMap < u64 , Function > = HashMap :: new ( ) ;
268+ for f in self . pe ( ) . net ( ) ?. functions ( ) {
269+ let addr = f. offset as u64 ;
270+ let calls_to = callers_of. remove ( & addr) . unwrap_or_default ( ) ;
271+ let calls_from = callees_of. remove ( & addr) . unwrap_or_default ( ) ;
272+ // `inrefs` is just the materialised caller list (the trait
273+ // returns `&Vec<u64>`; `calls_to` is a `HashSet`).
274+ let inrefs: Vec < u64 > = calls_to. iter ( ) . copied ( ) . collect ( ) ;
275+ // Single-block-per-method shape matches `get_blocks`.
276+ // First instruction's offset is the block key; no
277+ // intra-function successors at this granularity.
278+ let mut blockrefs: HashMap < u64 , Vec < u64 > > = HashMap :: new ( ) ;
279+ if let Some ( first) = f. instructions . first ( ) {
280+ blockrefs. insert ( first. offset as u64 , Vec :: new ( ) ) ;
202281 }
203282 methods. insert (
204- f . offset as u64 ,
283+ addr ,
205284 Function {
206285 f : f. clone ( ) ,
207- calls_to : HashSet :: new ( ) ,
286+ calls_to,
208287 calls_from,
288+ inrefs,
289+ blockrefs,
209290 } ,
210291 ) ;
211292 }
212- for ( a, calls_from) in calls_to_map. into_iter ( ) {
213- if let Some ( f) = methods. get_mut ( & a) {
214- f. calls_from = calls_from;
215- }
216- }
293+
217294 Ok ( methods
218295 . into_iter ( )
219296 . map ( |( a, b) | ( a, Box :: new ( b) as Box < dyn super :: Function > ) )
@@ -654,7 +731,13 @@ impl<'data> Extractor<'data> {
654731 & self ,
655732 f : & Function ,
656733 ) -> Result < Vec < ( crate :: rules:: features:: Feature , u64 ) > > {
657- Ok ( f. calls_to
734+ // 0.5.0: read `calls_from` (callees) not `calls_to` (callers).
735+ // Pre-0.5.0 this was a copy-paste from
736+ // `extract_function_call_to_features` that silently emitted the
737+ // caller set for every `characteristic: calls from` rule on
738+ // .NET assemblies. Matches Python capa's
739+ // `extract_function_calls_from` in dnfile/function.py.
740+ Ok ( f. calls_from
658741 . iter ( )
659742 . map ( |a| {
660743 (
0 commit comments