fiber_audit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.fiber-audit.example.yml +33 -0
  3. data/CHANGELOG.md +32 -0
  4. data/README.md +150 -0
  5. data/bin/fiber-audit +5 -0
  6. data/lib/fiber_audit/audit.rb +288 -0
  7. data/lib/fiber_audit/cli.rb +245 -0
  8. data/lib/fiber_audit/configuration.rb +236 -0
  9. data/lib/fiber_audit/correlation/fingerprint.rb +26 -0
  10. data/lib/fiber_audit/errors.rb +8 -0
  11. data/lib/fiber_audit/execution_context.rb +47 -0
  12. data/lib/fiber_audit/findings/collection.rb +58 -0
  13. data/lib/fiber_audit/findings/confidence.rb +19 -0
  14. data/lib/fiber_audit/findings/evidence.rb +13 -0
  15. data/lib/fiber_audit/findings/finding.rb +76 -0
  16. data/lib/fiber_audit/findings/location.rb +9 -0
  17. data/lib/fiber_audit/findings/severity.rb +19 -0
  18. data/lib/fiber_audit/project.rb +96 -0
  19. data/lib/fiber_audit/reporters/base.rb +12 -0
  20. data/lib/fiber_audit/reporters/json.rb +34 -0
  21. data/lib/fiber_audit/reporters/schema.rb +574 -0
  22. data/lib/fiber_audit/reporters/text.rb +179 -0
  23. data/lib/fiber_audit/static/call_site.rb +71 -0
  24. data/lib/fiber_audit/static/call_site_extractor.rb +524 -0
  25. data/lib/fiber_audit/static/execution_context_resolver.rb +266 -0
  26. data/lib/fiber_audit/static/rules/base.rb +185 -0
  27. data/lib/fiber_audit/static/rules/blocking_subprocess.rb +94 -0
  28. data/lib/fiber_audit/static/rules/built_ins.rb +36 -0
  29. data/lib/fiber_audit/static/rules/direct_socket.rb +112 -0
  30. data/lib/fiber_audit/static/rules/io_select.rb +104 -0
  31. data/lib/fiber_audit/static/rules/net_http_in_request.rb +116 -0
  32. data/lib/fiber_audit/static/rules/registry.rb +123 -0
  33. data/lib/fiber_audit/static/rules/synchronization.rb +124 -0
  34. data/lib/fiber_audit/static/rules/thread_current_state.rb +113 -0
  35. data/lib/fiber_audit/static/rules/thread_join.rb +96 -0
  36. data/lib/fiber_audit/static/semantic_index.rb +300 -0
  37. data/lib/fiber_audit/suppressions/parser.rb +146 -0
  38. data/lib/fiber_audit/suppressions/store.rb +63 -0
  39. data/lib/fiber_audit/version.rb +5 -0
  40. data/lib/fiber_audit.rb +40 -0
  41. metadata +108 -0
@@ -0,0 +1,524 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+ require_relative 'call_site'
5
+
6
+ module FiberAudit
7
+ module Static
8
+ # Extracts CallSite objects from Ruby source files using Prism.
9
+ # Performs single-pass parsing per file with receiver inference.
10
+ class CallSiteExtractor
11
+ # Error from parsing a single file
12
+ ParseError = Data.define(:path, :message, :line)
13
+
14
+ # Result of extraction across multiple files
15
+ Result = Data.define(:call_sites, :parse_errors)
16
+
17
+ # Well-known constants that can be resolved without semantic index
18
+ WELL_KNOWN_CONSTANTS = %w[
19
+ Kernel
20
+ Open3
21
+ IO
22
+ Process
23
+ Thread
24
+ Mutex
25
+ ConditionVariable
26
+ Monitor
27
+ MonitorMixin
28
+ TCPSocket
29
+ TCPServer
30
+ UDPSocket
31
+ UNIXSocket
32
+ UNIXServer
33
+ Socket
34
+ IPSocket
35
+ Net::HTTP
36
+ URI
37
+ OpenURI
38
+ ActiveSupport::CurrentAttributes
39
+ File
40
+ Dir
41
+ Pathname
42
+ Redis
43
+ Net::SMTP
44
+ Net::FTP
45
+ Net::IMAP
46
+ ].to_set.freeze
47
+
48
+ def initialize(files:, semantic_index: nil)
49
+ @files = Array(files)
50
+ @sem = semantic_index
51
+ end
52
+
53
+ # Extract call sites from all files. Returns Result.
54
+ # Never raises; collects parse errors per file.
55
+ # Maintains deterministic order: files processed in provided order.
56
+ def call
57
+ call_sites = []
58
+ parse_errors = []
59
+
60
+ @files.each do |file_path|
61
+ extract_from_file(file_path, call_sites, parse_errors)
62
+ end
63
+
64
+ Result.new(call_sites: call_sites, parse_errors: parse_errors)
65
+ end
66
+
67
+ private
68
+
69
+ def extract_from_file(file_path, call_sites, parse_errors)
70
+ # Read file once
71
+ source = File.read(file_path)
72
+
73
+ # Parse once with Prism
74
+ parse_result = Prism.parse(source)
75
+
76
+ # Collect parse errors and skip traversal if any
77
+ if parse_result.errors.any?
78
+ parse_result.errors.each do |error|
79
+ parse_errors << ParseError.new(
80
+ path: file_path,
81
+ message: error.message,
82
+ line: error.location&.start_line
83
+ )
84
+ end
85
+ return
86
+ end
87
+
88
+ # Walk the AST
89
+ visitor = ASTVisitor.new(
90
+ file_path: file_path,
91
+ source: source,
92
+ semantic_index: @sem
93
+ )
94
+ visitor.visit(parse_result.value)
95
+
96
+ call_sites.concat(visitor.call_sites)
97
+ rescue Errno::ENOENT
98
+ parse_errors << ParseError.new(
99
+ path: file_path,
100
+ message: "No such file or directory: #{file_path}",
101
+ line: nil
102
+ )
103
+ rescue StandardError => e
104
+ parse_errors << ParseError.new(
105
+ path: file_path,
106
+ message: e.message,
107
+ line: nil
108
+ )
109
+ end
110
+
111
+ # Internal visitor that walks the AST and extracts call sites. Keeping the
112
+ # traversal state together makes scope propagation explicit.
113
+ # rubocop:disable Metrics/ClassLength
114
+ class ASTVisitor
115
+ attr_reader :call_sites
116
+
117
+ def initialize(file_path:, source:, semantic_index:)
118
+ @file_path = file_path
119
+ @source = source
120
+ @semantic_index = semantic_index
121
+ @call_sites = []
122
+
123
+ # Scope tracking stacks
124
+ @nesting_stack = [] # Array of constant names (class/module nesting)
125
+ @scope_stack = [] # Array of [class_or_module, method_name, method_kind]
126
+ @in_singleton_class = false # Track if inside `class << self`
127
+
128
+ # Assignment tracking: local variable -> {constant:, confidence:}
129
+ # Scoped per method; no ivars attached to Prism nodes.
130
+ @assignment_scope = {}
131
+ end
132
+
133
+ def visit(node)
134
+ return unless node
135
+
136
+ case node
137
+ when Prism::ClassNode
138
+ visit_class(node)
139
+ when Prism::ModuleNode
140
+ visit_module(node)
141
+ when Prism::SingletonClassNode
142
+ visit_singleton_class(node)
143
+ when Prism::DefNode
144
+ visit_def(node)
145
+ when Prism::IfNode, Prism::UnlessNode, Prism::CaseNode
146
+ visit_branching(node)
147
+ when Prism::CallNode
148
+ visit_call(node)
149
+ when Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode
150
+ visit_assignment(node)
151
+ else
152
+ visit_children(node)
153
+ end
154
+ end
155
+
156
+ private
157
+
158
+ # --- Class/Module/SingletonClass visitors ---
159
+
160
+ def visit_class(node)
161
+ visit_constant_scope(node.constant_path, :instance) do
162
+ visit_children(node)
163
+ end
164
+ end
165
+
166
+ def visit_module(node)
167
+ visit_constant_scope(node.constant_path, :module) do
168
+ visit_children(node)
169
+ end
170
+ end
171
+
172
+ def visit_constant_scope(constant_path, kind)
173
+ qualified_name = qualified_declaration_name(constant_path)
174
+ @nesting_stack.push(qualified_name) if qualified_name
175
+ @scope_stack.push([qualified_name, nil, kind])
176
+ saved_scope = @assignment_scope
177
+ @assignment_scope = {}
178
+
179
+ yield
180
+ ensure
181
+ @assignment_scope = saved_scope
182
+ @scope_stack.pop
183
+ @nesting_stack.pop if qualified_name
184
+ end
185
+
186
+ def visit_singleton_class(node)
187
+ # `class << self` or `class << expr` - mark as singleton for def method_kind
188
+ prev_singleton = @in_singleton_class
189
+ @in_singleton_class = true
190
+ visit_children(node)
191
+ @in_singleton_class = prev_singleton
192
+ end
193
+
194
+ def visit_def(node)
195
+ method_name = node.name.to_s
196
+ method_kind = determine_method_kind(node)
197
+
198
+ enclosing = explicit_method_receiver(node) || current_enclosing_constant
199
+ @scope_stack.push([enclosing, method_name, method_kind])
200
+
201
+ saved_scope = @assignment_scope
202
+ @assignment_scope = {}
203
+
204
+ visit_children(node)
205
+
206
+ @assignment_scope = saved_scope
207
+ @scope_stack.pop
208
+ end
209
+
210
+ # --- Branching visitors (if/unless/case) ---
211
+ # Assignments in branches cannot leak to outer scope conservatively.
212
+
213
+ def visit_branching(node)
214
+ # Each direct branch starts from the same pre-branch bindings. This
215
+ # prevents assignments in one branch from influencing calls in a
216
+ # sibling branch, and no branch-local assignment leaks afterward.
217
+ pre_scope = @assignment_scope.dup
218
+ node.compact_child_nodes.each do |child|
219
+ @assignment_scope = pre_scope.dup
220
+ visit(child)
221
+ end
222
+ ensure
223
+ @assignment_scope = pre_scope if pre_scope
224
+ end
225
+
226
+ # --- Call node visitor ---
227
+
228
+ def visit_call(node)
229
+ # Extract call site with local assignment tracking (no ivars on nodes)
230
+ call_site = build_call_site(node)
231
+ @call_sites << call_site if call_site
232
+
233
+ # Recurse into receiver, arguments, and attached block body.
234
+ visit(node.receiver) if node.receiver
235
+ visit(node.arguments) if node.arguments
236
+ visit(node.block) if node.block
237
+ end
238
+
239
+ def visit_assignment(node)
240
+ var_name = node.name.to_s
241
+ value_node = node.value
242
+
243
+ # Determine what's being assigned
244
+ assigned_info = compute_assignment_info(value_node)
245
+
246
+ if assigned_info
247
+ @assignment_scope[var_name] = assigned_info
248
+ else
249
+ # Unsupported assignment (not .new, not a constant, not a call) -> invalidate
250
+ @assignment_scope.delete(var_name)
251
+ end
252
+
253
+ visit(value_node)
254
+ end
255
+
256
+ def visit_children(node)
257
+ node.compact_child_nodes.each do |child|
258
+ visit(child)
259
+ end
260
+ rescue NoMethodError
261
+ node.child_nodes.compact.each { |child| visit(child) }
262
+ end
263
+
264
+ # --- Assignment info computation ---
265
+ # Returns {constant: String|nil, confidence: Symbol} or nil to invalidate.
266
+
267
+ def compute_assignment_info(value_node)
268
+ case value_node
269
+ when Prism::CallNode
270
+ compute_call_assignment_info(value_node)
271
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
272
+ # Direct constant assignment: `x = SomeConst`
273
+ resolved = resolve_constant_name(extract_constant_from_node(value_node))
274
+ { constant: resolved, confidence: :high } if resolved
275
+ end
276
+ end
277
+
278
+ def compute_call_assignment_info(call_node)
279
+ receiver_const = extract_constant_from_node(call_node.receiver)
280
+ resolved = resolve_constant_name(receiver_const)
281
+
282
+ # Thread.current returns the current Thread instance. Keep this narrow:
283
+ # arbitrary singleton methods do not imply their receiver's type.
284
+ return { constant: resolved, confidence: :high } if call_node.name == :current && resolved == 'Thread'
285
+
286
+ if call_node.name == :new && call_node.receiver
287
+ # Constructor call: `x = SomeConst.new(...)`
288
+ if resolved
289
+ { constant: resolved, confidence: :high }
290
+ else
291
+ # Unresolved or bare .new stays heuristic; never fabricate high.
292
+ { constant: nil, confidence: :low }
293
+ end
294
+ else
295
+ # Builder/other call: `x = build_something`
296
+ { constant: nil, confidence: :low }
297
+ end
298
+ end
299
+
300
+ def resolve_constant_name(const_name)
301
+ return nil unless const_name
302
+
303
+ semantic_receiver_constant(const_name) || well_known_receiver_constant(const_name)
304
+ end
305
+
306
+ # Check if a constant name is resolvable (well-known or semantic index)
307
+ def resolved_constant?(const_name)
308
+ !resolve_constant_name(const_name).nil?
309
+ end
310
+
311
+ # --- Call site building ---
312
+
313
+ def build_call_site(node)
314
+ receiver_source = extract_receiver_source(node)
315
+ method_name = node.name
316
+ arguments = extract_arguments(node)
317
+ enclosing_symbol = build_enclosing_symbol
318
+ nesting = @nesting_stack.dup
319
+ receiver_constant = resolve_receiver_constant(node, receiver_source)
320
+
321
+ resolution = build_resolution(receiver_constant, receiver_source, method_name)
322
+ confidence = determine_confidence(receiver_constant, receiver_source)
323
+
324
+ CallSite.new(
325
+ path: @file_path,
326
+ line: node.location.start_line,
327
+ column: node.location.start_column,
328
+ receiver_source: receiver_source,
329
+ receiver_constant: receiver_constant,
330
+ method_name: method_name,
331
+ arguments: arguments,
332
+ enclosing_symbol: enclosing_symbol,
333
+ nesting: nesting,
334
+ execution_context: nil,
335
+ resolution: resolution,
336
+ confidence: confidence
337
+ )
338
+ end
339
+
340
+ # --- Receiver extraction ---
341
+
342
+ def extract_receiver_source(node)
343
+ return nil unless node.receiver
344
+
345
+ receiver = node.receiver
346
+ loc = receiver.location
347
+ @source[loc.start_offset...loc.end_offset]
348
+ end
349
+
350
+ def extract_arguments(node)
351
+ return [] unless node.arguments
352
+
353
+ node.arguments.arguments.map do |arg|
354
+ loc = arg.location
355
+ @source[loc.start_offset...loc.end_offset]
356
+ end
357
+ end
358
+
359
+ # --- Constant resolution ---
360
+
361
+ def resolve_receiver_constant(node, receiver_source)
362
+ return nil unless receiver_source
363
+
364
+ inferred = chained_constructor_constant(node) ||
365
+ assigned_receiver_constant(node) ||
366
+ direct_receiver_constant(node)
367
+ return inferred if inferred
368
+
369
+ semantic_receiver_constant(receiver_source) ||
370
+ well_known_receiver_constant(receiver_source)
371
+ end
372
+
373
+ def chained_constructor_constant(node)
374
+ return unless node.receiver.is_a?(Prism::CallNode) && node.receiver.name == :new
375
+
376
+ extract_chain_constructor(node.receiver)
377
+ end
378
+
379
+ def assigned_receiver_constant(node)
380
+ return unless node.receiver.is_a?(Prism::LocalVariableReadNode)
381
+
382
+ info = @assignment_scope[node.receiver.name.to_s]
383
+ info[:constant] if info && info[:confidence] == :high
384
+ end
385
+
386
+ def direct_receiver_constant(node)
387
+ return unless node.receiver.is_a?(Prism::ConstantReadNode) ||
388
+ node.receiver.is_a?(Prism::ConstantPathNode)
389
+
390
+ resolve_constant_name(extract_constant_from_node(node.receiver))
391
+ end
392
+
393
+ def semantic_receiver_constant(receiver_source)
394
+ return unless @semantic_index
395
+
396
+ @nesting_stack.length.downto(0) do |length|
397
+ resolved = @semantic_index.resolve_constant(
398
+ receiver_source,
399
+ nesting: @nesting_stack.first(length)
400
+ )
401
+ return resolved.name if resolved
402
+ end
403
+ nil
404
+ rescue StandardError
405
+ nil
406
+ end
407
+
408
+ def well_known_receiver_constant(receiver_source)
409
+ receiver_source if WELL_KNOWN_CONSTANTS.include?(receiver_source)
410
+ end
411
+
412
+ # Extract constructor constant from a direct chain like Thread.new.join
413
+ def extract_chain_constructor(new_call)
414
+ return nil unless new_call.is_a?(Prism::CallNode) && new_call.name == :new
415
+
416
+ if new_call.receiver.is_a?(Prism::ConstantReadNode) ||
417
+ new_call.receiver.is_a?(Prism::ConstantPathNode)
418
+ return resolve_constant_name(extract_constant_from_node(new_call.receiver))
419
+ end
420
+
421
+ nil
422
+ end
423
+
424
+ # --- Constant name extraction ---
425
+
426
+ def extract_constant_from_node(node)
427
+ return nil unless node
428
+
429
+ case node
430
+ when Prism::ConstantReadNode
431
+ node.name.to_s
432
+ when Prism::ConstantPathNode
433
+ build_constant_path_string(node)
434
+ end
435
+ end
436
+
437
+ # Build fully qualified constant path string like "Net::HTTP"
438
+ def build_constant_path_string(node)
439
+ parts = []
440
+ current = node
441
+ while current.is_a?(Prism::ConstantPathNode)
442
+ parts.unshift(current.name.to_s)
443
+ current = current.parent
444
+ end
445
+ parts.unshift(current.name.to_s) if current.is_a?(Prism::ConstantReadNode)
446
+ parts.join('::')
447
+ end
448
+
449
+ # Return the fully qualified lexical declaration name. Ruby treats
450
+ # `class Outer::Inner` as one nesting entry, while nested class/module
451
+ # bodies add their fully qualified name to the existing lexical stack.
452
+ def qualified_declaration_name(node)
453
+ name = extract_constant_from_node(node)
454
+ return nil unless name
455
+ return name if name.include?('::') || @nesting_stack.empty?
456
+
457
+ "#{@nesting_stack.last}::#{name}"
458
+ end
459
+
460
+ # --- Resolution and confidence ---
461
+
462
+ def build_resolution(receiver_constant, receiver_source, method_name)
463
+ if receiver_constant
464
+ "#{receiver_constant}.#{method_name}"
465
+ elsif receiver_source
466
+ "#{receiver_source}.#{method_name}"
467
+ end
468
+ end
469
+
470
+ def determine_confidence(receiver_constant, receiver_source)
471
+ if receiver_constant
472
+ :high
473
+ elsif receiver_source
474
+ :low
475
+ else
476
+ :unknown
477
+ end
478
+ end
479
+
480
+ # --- Enclosing symbol ---
481
+ # Calls in class/module body without a method have enclosing_symbol nil.
482
+
483
+ def build_enclosing_symbol
484
+ return nil if @scope_stack.empty?
485
+
486
+ # Find the most recent method in scope stack
487
+ @scope_stack.reverse.each do |const, method, kind|
488
+ next unless method
489
+
490
+ prefix = const || ''
491
+ case kind
492
+ when :instance
493
+ return prefix.empty? ? "##{method}" : "#{prefix}##{method}"
494
+ when :class
495
+ return prefix.empty? ? ".#{method}" : "#{prefix}.#{method}"
496
+ end
497
+ end
498
+
499
+ # No method found (call in class/module body) -> nil
500
+ nil
501
+ end
502
+
503
+ # --- Method kind determination ---
504
+
505
+ def determine_method_kind(node)
506
+ return :class if node.receiver || @in_singleton_class
507
+
508
+ :instance
509
+ end
510
+
511
+ def explicit_method_receiver(node)
512
+ return unless node.receiver && !node.receiver.is_a?(Prism::SelfNode)
513
+
514
+ extract_constant_from_node(node.receiver)
515
+ end
516
+
517
+ def current_enclosing_constant
518
+ @nesting_stack.last
519
+ end
520
+ end
521
+ # rubocop:enable Metrics/ClassLength
522
+ end
523
+ end
524
+ end