ruby_ability_graph 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.
@@ -0,0 +1,452 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RubyAbilityGraph
6
+ # Renders scan results (plus optional policy-check violations) as a single
7
+ # self-contained HTML file: an interactive role -> action -> resource graph,
8
+ # edges colored/dashed by confidence and flagged where a policy violation
9
+ # applies. No server, no external requests, no CDN assets -- everything
10
+ # (CSS, JS, data) is inlined so the file opens straight from disk. That
11
+ # matches the README's trust story: this tool runs locally and never phones
12
+ # home, and the report it produces shouldn't either.
13
+ #
14
+ # Untrusted data (role/action/model names, raw conditions) comes from the
15
+ # scanned app's own source, so it's only ever passed through as JSON and
16
+ # rendered client-side via textContent/DOM APIs -- never interpolated into
17
+ # HTML or innerHTML -- to avoid the report becoming an XSS vector against
18
+ # whoever opens it.
19
+ class HtmlReport
20
+ DATA_PLACEHOLDER = "/*__RUBY_ABILITY_GRAPH_DATA__*/"
21
+
22
+ # The quoted heredoc delimiter isn't about interpolation -- there's no
23
+ # Ruby interpolation syntax anywhere below. It's what stops Ruby from
24
+ # interpreting the embedded JS's own backslash escape sequences as if
25
+ # they were Ruby's own string escapes.
26
+ TEMPLATE = <<~'HTML'
27
+ <!doctype html>
28
+ <html lang="en">
29
+ <head>
30
+ <meta charset="utf-8">
31
+ <meta name="viewport" content="width=device-width, initial-scale=1">
32
+ <title>Ruby Ability Graph Report</title>
33
+ <style>
34
+ :root {
35
+ color-scheme: light;
36
+ --surface: #fcfcfb;
37
+ --plane: #f9f9f7;
38
+ --ink: #0b0b0b;
39
+ --ink-2: #52514e;
40
+ --muted: #898781;
41
+ --grid: #e1e0d9;
42
+ --border: rgba(11,11,11,0.10);
43
+ --good: #0ca30c;
44
+ --warning: #fab219;
45
+ --critical: #d03b3b;
46
+ }
47
+ @media (prefers-color-scheme: dark) {
48
+ :root {
49
+ color-scheme: dark;
50
+ --surface: #1a1a19;
51
+ --plane: #0d0d0d;
52
+ --ink: #ffffff;
53
+ --ink-2: #c3c2b7;
54
+ --muted: #898781;
55
+ --grid: #2c2c2a;
56
+ --border: rgba(255,255,255,0.10);
57
+ }
58
+ }
59
+ * { box-sizing: border-box; }
60
+ body {
61
+ margin: 0;
62
+ background: var(--plane);
63
+ color: var(--ink);
64
+ font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
65
+ }
66
+ header, footer { padding: 20px 24px; }
67
+ header h1 { margin: 0 0 4px; font-size: 18px; }
68
+ header p { margin: 0; color: var(--ink-2); }
69
+ #summary { display: flex; gap: 24px; flex-wrap: wrap; margin-top: 12px; }
70
+ #summary div { color: var(--ink-2); }
71
+ #summary strong { color: var(--ink); }
72
+ #legend { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 14px; font-size: 13px; color: var(--ink-2); }
73
+ #legend span { display: inline-flex; align-items: center; gap: 6px; }
74
+ .swatch { display: inline-block; width: 22px; height: 0; border-top-width: 3px; border-top-style: solid; }
75
+ .swatch.resolved { border-color: var(--good); border-top-style: solid; }
76
+ .swatch.unsupported { border-color: var(--warning); border-top-style: dashed; }
77
+ .swatch.violation { border-color: var(--critical); border-top-style: solid; }
78
+ main {
79
+ margin: 0 24px 24px;
80
+ background: var(--surface);
81
+ border: 1px solid var(--border);
82
+ border-radius: 8px;
83
+ overflow: auto;
84
+ max-height: 80vh;
85
+ }
86
+ svg { display: block; width: 100%; height: auto; }
87
+ .node circle { fill: var(--surface); stroke: var(--muted); stroke-width: 1.5px; }
88
+ .node text { fill: var(--ink-2); font-size: 12px; }
89
+ .node.active circle { stroke: var(--ink); stroke-width: 2px; }
90
+ .node.active text { fill: var(--ink); }
91
+ .edge { fill: none; stroke-width: 2px; cursor: pointer; }
92
+ .edge.resolved { stroke: var(--good); }
93
+ .edge.unsupported { stroke: var(--warning); stroke-dasharray: 6 4; }
94
+ .edge.violation { stroke: var(--critical); stroke-width: 3px; }
95
+ svg.hovering .node, svg.hovering .edge { opacity: 0.15; transition: opacity 0.12s; }
96
+ svg.hovering .active { opacity: 1; }
97
+ #empty { padding: 40px; text-align: center; color: var(--muted); }
98
+ #tooltip {
99
+ position: fixed;
100
+ display: none;
101
+ max-width: 320px;
102
+ padding: 8px 10px;
103
+ background: var(--surface);
104
+ color: var(--ink);
105
+ border: 1px solid var(--border);
106
+ border-radius: 6px;
107
+ box-shadow: 0 4px 16px rgba(0,0,0,0.18);
108
+ font-size: 12px;
109
+ pointer-events: none;
110
+ z-index: 10;
111
+ white-space: pre-line;
112
+ }
113
+ footer { color: var(--muted); font-size: 12px; }
114
+ </style>
115
+ </head>
116
+ <body>
117
+ <header>
118
+ <h1>Ruby Ability Graph Report</h1>
119
+ <p>Role &rarr; action &rarr; resource, from a ruby_ability_graph scan. Hover a node to trace its reach, hover an edge for detail.</p>
120
+ <div id="summary">
121
+ <div id="coverage-line"></div>
122
+ <div id="violation-line"></div>
123
+ </div>
124
+ <div id="legend">
125
+ <span><span class="swatch resolved"></span> Resolved</span>
126
+ <span><span class="swatch unsupported"></span> Unsupported -- not analyzed</span>
127
+ <span><span class="swatch violation"></span> Policy violation</span>
128
+ </div>
129
+ </header>
130
+ <main>
131
+ <svg id="graph" viewBox="0 0 960 400" role="img" aria-label="Role to action to resource access graph"></svg>
132
+ <div id="empty" hidden>No allowed role &times; action &times; resource combinations found.</div>
133
+ </main>
134
+ <footer>Generated locally by ruby_ability_graph. This file contains no external requests -- open it offline.</footer>
135
+ <div id="tooltip"></div>
136
+ <script>
137
+ (function () {
138
+ "use strict";
139
+ var DATA = /*__RUBY_ABILITY_GRAPH_DATA__*/;
140
+ var SVGNS = "http://www.w3.org/2000/svg";
141
+ // A control character, not a display separator -- joins role/action/model
142
+ // names into map keys so a name that happens to contain a plain space
143
+ // can't collide with a different (role, action) or (action, model) pair.
144
+ // Built at runtime (not written as a literal escape) so it stays a plain
145
+ // JS string, unambiguous from Ruby's own escape handling of this template.
146
+ var SEP = String.fromCharCode(0);
147
+ var ROW_HEIGHT = 28;
148
+ var WIDTH = 960;
149
+ var MARGIN = 110;
150
+
151
+ function svgEl(name, attrs) {
152
+ var el = document.createElementNS(SVGNS, name);
153
+ for (var k in attrs) { el.setAttribute(k, attrs[k]); }
154
+ return el;
155
+ }
156
+
157
+ function uniqueSorted(values) {
158
+ return Array.from(new Set(values)).sort();
159
+ }
160
+
161
+ function positions(names, height) {
162
+ var step = height / (names.length + 1);
163
+ var map = {};
164
+ names.forEach(function (name, i) { map[name] = step * (i + 1); });
165
+ return map;
166
+ }
167
+
168
+ function edgeStatus(rows) {
169
+ if (rows.some(function (r) { return r.violation; })) { return "violation"; }
170
+ return rows.every(function (r) { return r.confidence === "resolved"; }) ? "resolved" : "unsupported";
171
+ }
172
+
173
+ function groupBy(rows, keyFn, shapeFn) {
174
+ var map = new Map();
175
+ rows.forEach(function (row) {
176
+ var key = keyFn(row);
177
+ if (!map.has(key)) { map.set(key, shapeFn(row)); }
178
+ map.get(key).rows.push(row);
179
+ });
180
+ return Array.from(map.values());
181
+ }
182
+
183
+ function byField(edges, field) {
184
+ var map = {};
185
+ edges.forEach(function (edge) {
186
+ (map[edge[field]] = map[edge[field]] || []).push(edge);
187
+ });
188
+ return map;
189
+ }
190
+
191
+ function fillCoverage() {
192
+ var c = DATA.coverage;
193
+ document.getElementById("coverage-line").innerHTML =
194
+ "<strong>" + c.resolved + "/" + c.total + "</strong> resolved (" + c.pct + "%)";
195
+ var v = document.getElementById("violation-line");
196
+ if (DATA.violationCount === null || DATA.violationCount === undefined) {
197
+ v.textContent = "No policy file was checked.";
198
+ } else if (DATA.violationCount === 0) {
199
+ v.textContent = "Policy check: no violations.";
200
+ } else {
201
+ v.textContent = DATA.violationCount + " policy violation(s) -- highlighted in red below.";
202
+ }
203
+ }
204
+
205
+ function render() {
206
+ fillCoverage();
207
+ var rows = DATA.rows;
208
+ var svg = document.getElementById("graph");
209
+ if (!rows.length) {
210
+ svg.setAttribute("hidden", "hidden");
211
+ document.getElementById("empty").hidden = false;
212
+ return;
213
+ }
214
+
215
+ var roles = uniqueSorted(rows.map(function (r) { return r.role; }));
216
+ var actions = uniqueSorted(rows.map(function (r) { return r.action; }));
217
+ var models = uniqueSorted(rows.map(function (r) { return r.model; }));
218
+ var height = Math.max(360, (Math.max(roles.length, actions.length, models.length) + 1) * ROW_HEIGHT);
219
+
220
+ svg.setAttribute("viewBox", "0 0 " + WIDTH + " " + height);
221
+ var roleX = MARGIN, actionX = WIDTH / 2, modelX = WIDTH - MARGIN;
222
+ var roleY = positions(roles, height), actionY = positions(actions, height), modelY = positions(models, height);
223
+
224
+ var raEdges = groupBy(
225
+ rows,
226
+ function (r) { return r.role + SEP + r.action; },
227
+ function (r) { return { role: r.role, action: r.action, rows: [] }; }
228
+ );
229
+ var amEdges = groupBy(
230
+ rows,
231
+ function (r) { return r.action + SEP + r.model; },
232
+ function (r) { return { action: r.action, model: r.model, rows: [] }; }
233
+ );
234
+ var raByRole = byField(raEdges, "role"), raByAction = byField(raEdges, "action");
235
+ var amByAction = byField(amEdges, "action"), amByModel = byField(amEdges, "model");
236
+
237
+ var elements = { role: {}, action: {}, model: {}, ra: {}, am: {} };
238
+
239
+ function edgeKey(edge, a, b) { return edge[a] + SEP + edge[b]; }
240
+
241
+ function drawEdge(x1, y1, x2, y2, statusRows, store, key) {
242
+ var midX = (x1 + x2) / 2;
243
+ var path = svgEl("path", {
244
+ "class": "edge " + edgeStatus(statusRows),
245
+ d: "M " + x1 + "," + y1 + " C " + midX + "," + y1 + " " + midX + "," + y2 + " " + x2 + "," + y2
246
+ });
247
+ svg.appendChild(path);
248
+ store[key] = path;
249
+ return path;
250
+ }
251
+
252
+ raEdges.forEach(function (edge) {
253
+ var key = edgeKey(edge, "role", "action");
254
+ var path = drawEdge(roleX, roleY[edge.role], actionX, actionY[edge.action], edge.rows, elements.ra, key);
255
+ path.addEventListener("mouseenter", function (ev) { hoverRoleAction(edge); showTooltip(ev, raTooltip(edge)); });
256
+ path.addEventListener("mousemove", function (ev) { moveTooltip(ev); });
257
+ path.addEventListener("mouseleave", clearHover);
258
+ });
259
+
260
+ amEdges.forEach(function (edge) {
261
+ var key = edgeKey(edge, "action", "model");
262
+ var path = drawEdge(actionX, actionY[edge.action], modelX, modelY[edge.model], edge.rows, elements.am, key);
263
+ path.addEventListener("mouseenter", function (ev) { hoverActionModel(edge); showTooltip(ev, amTooltip(edge)); });
264
+ path.addEventListener("mousemove", function (ev) { moveTooltip(ev); });
265
+ path.addEventListener("mouseleave", clearHover);
266
+ });
267
+
268
+ function drawNode(name, x, y, kind, anchor, store, onHover) {
269
+ var g = svgEl("g", { "class": "node " + kind });
270
+ var circle = svgEl("circle", { cx: x, cy: y, r: 5 });
271
+ var title = document.createElementNS(SVGNS, "title");
272
+ title.textContent = name;
273
+ circle.appendChild(title);
274
+ var text = svgEl("text", {
275
+ x: x + (anchor === "end" ? -10 : anchor === "start" ? 10 : 0),
276
+ y: anchor === "middle" ? y - 10 : y + 4,
277
+ "text-anchor": anchor
278
+ });
279
+ text.textContent = name;
280
+ g.appendChild(circle);
281
+ g.appendChild(text);
282
+ g.addEventListener("mouseenter", onHover);
283
+ g.addEventListener("mouseleave", clearHover);
284
+ svg.appendChild(g);
285
+ store[name] = g;
286
+ }
287
+
288
+ roles.forEach(function (name) {
289
+ drawNode(name, roleX, roleY[name], "role", "end", elements.role, function () { hoverRole(name); });
290
+ });
291
+ actions.forEach(function (name) {
292
+ drawNode(name, actionX, actionY[name], "action", "middle", elements.action, function () { hoverAction(name); });
293
+ });
294
+ models.forEach(function (name) {
295
+ drawNode(name, modelX, modelY[name], "model", "start", elements.model, function () { hoverModel(name); });
296
+ });
297
+
298
+ function activate(roleNames, actionNames, modelNames, raList, amList) {
299
+ svg.classList.add("hovering");
300
+ Array.from(svg.querySelectorAll(".active")).forEach(function (el) { el.classList.remove("active"); });
301
+ roleNames.forEach(function (n) { elements.role[n] && elements.role[n].classList.add("active"); });
302
+ actionNames.forEach(function (n) { elements.action[n] && elements.action[n].classList.add("active"); });
303
+ modelNames.forEach(function (n) { elements.model[n] && elements.model[n].classList.add("active"); });
304
+ raList.forEach(function (e) { elements.ra[edgeKey(e, "role", "action")].classList.add("active"); });
305
+ amList.forEach(function (e) { elements.am[edgeKey(e, "action", "model")].classList.add("active"); });
306
+ }
307
+
308
+ function clearHover() {
309
+ svg.classList.remove("hovering");
310
+ Array.from(svg.querySelectorAll(".active")).forEach(function (el) { el.classList.remove("active"); });
311
+ hideTooltip();
312
+ }
313
+
314
+ function hoverRole(role) {
315
+ var ra = raByRole[role] || [];
316
+ var actionNames = uniqueSorted(ra.map(function (e) { return e.action; }));
317
+ var am = [];
318
+ actionNames.forEach(function (a) { am = am.concat(amByAction[a] || []); });
319
+ var modelNames = uniqueSorted(am.map(function (e) { return e.model; }));
320
+ activate([role], actionNames, modelNames, ra, am);
321
+ }
322
+
323
+ function hoverAction(action) {
324
+ var ra = raByAction[action] || [];
325
+ var am = amByAction[action] || [];
326
+ activate(uniqueSorted(ra.map(function (e) { return e.role; })), [action],
327
+ uniqueSorted(am.map(function (e) { return e.model; })), ra, am);
328
+ }
329
+
330
+ function hoverModel(model) {
331
+ var am = amByModel[model] || [];
332
+ var actionNames = uniqueSorted(am.map(function (e) { return e.action; }));
333
+ var ra = [];
334
+ actionNames.forEach(function (a) { ra = ra.concat(raByAction[a] || []); });
335
+ activate(uniqueSorted(ra.map(function (e) { return e.role; })), actionNames, [model], ra, am);
336
+ }
337
+
338
+ function hoverRoleAction(edge) {
339
+ activate([edge.role], [edge.action], uniqueSorted(edge.rows.map(function (r) { return r.model; })),
340
+ [edge], []);
341
+ }
342
+
343
+ function hoverActionModel(edge) {
344
+ activate(uniqueSorted(edge.rows.map(function (r) { return r.role; })), [edge.action], [edge.model],
345
+ [], [edge]);
346
+ }
347
+
348
+ function rowLine(label, row) {
349
+ var bits = [label + ": " + row.confidence];
350
+ if (row.condition !== null && row.condition !== undefined) { bits.push(JSON.stringify(row.condition)); }
351
+ if (row.reasons && row.reasons.length) { bits.push("(" + row.reasons.join(", ") + ")"); }
352
+ if (row.violation) { bits.push("⚠ policy violation"); }
353
+ return bits.join(" ");
354
+ }
355
+
356
+ function raTooltip(edge) {
357
+ return [edge.role + " → " + edge.action]
358
+ .concat(edge.rows.map(function (r) { return rowLine(r.model, r); }))
359
+ .join("\n");
360
+ }
361
+
362
+ function amTooltip(edge) {
363
+ return [edge.action + " → " + edge.model]
364
+ .concat(edge.rows.map(function (r) { return rowLine(r.role, r); }))
365
+ .join("\n");
366
+ }
367
+ }
368
+
369
+ var tooltip = document.getElementById("tooltip");
370
+
371
+ function showTooltip(ev, text) {
372
+ tooltip.textContent = text;
373
+ tooltip.style.display = "block";
374
+ moveTooltip(ev);
375
+ }
376
+
377
+ function moveTooltip(ev) {
378
+ tooltip.style.left = (ev.clientX + 14) + "px";
379
+ tooltip.style.top = (ev.clientY + 14) + "px";
380
+ }
381
+
382
+ function hideTooltip() {
383
+ tooltip.style.display = "none";
384
+ }
385
+
386
+ render();
387
+ })();
388
+ </script>
389
+ </body>
390
+ </html>
391
+ HTML
392
+
393
+ def self.call(results:, violations: nil)
394
+ new(results: results, violations: violations).call
395
+ end
396
+
397
+ def initialize(results:, violations: nil)
398
+ @results = results
399
+ @violations = violations
400
+ end
401
+
402
+ def call
403
+ # Block form, not TEMPLATE.sub(DATA_PLACEHOLDER, embedded_json) -- a String
404
+ # replacement argument gets backreference processing (\1, \&, ...), which
405
+ # would silently mangle JSON containing backslashes (e.g. our own "<\/"
406
+ # escaping below, or a condition value with a literal backslash in it).
407
+ # The block form inserts its return value verbatim.
408
+ TEMPLATE.sub(DATA_PLACEHOLDER) { embedded_json }
409
+ end
410
+
411
+ private
412
+
413
+ # Escape "</" so a condition value containing a literal "</script>"
414
+ # can't break out of the inline <script> block it's embedded in.
415
+ def embedded_json
416
+ data.to_json.gsub("</", '<\/')
417
+ end
418
+
419
+ def data
420
+ { "rows" => allowed_rows, "coverage" => coverage, "violationCount" => @violations&.size }
421
+ end
422
+
423
+ # Only allowed=true rows are graph-worthy -- this is a "who can access
424
+ # what" diagram, not a dump of every denial.
425
+ def allowed_rows
426
+ violated = violated_triples
427
+ @results.select { |r| r["allowed"] }.map do |r|
428
+ {
429
+ "role" => r["role"], "action" => r["action"], "model" => r["model"],
430
+ "confidence" => r["confidence"], "condition" => r["condition"], "reasons" => r["reasons"] || [],
431
+ "violation" => violated.include?([r["role"], r["action"], r["model"]])
432
+ }
433
+ end
434
+ end
435
+
436
+ def violated_triples
437
+ (@violations || []).to_set { |v| [v["role"], v["action"], v["model"]] }
438
+ end
439
+
440
+ def coverage
441
+ resolved = @results.count { |r| r["confidence"] == "resolved" }
442
+ total = @results.size
443
+ { "resolved" => resolved, "total" => total, "pct" => coverage_pct(resolved, total) }
444
+ end
445
+
446
+ def coverage_pct(resolved, total)
447
+ return 0 if total.zero?
448
+
449
+ ((resolved.to_f / total) * 100).round(1)
450
+ end
451
+ end
452
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module RubyAbilityGraph
6
+ # Static source scan (via Prism, never executes the target) that lists
7
+ # every method called on `user` in Ability#initialize -- a roles-file
8
+ # authoring aid.
9
+ class Inspector
10
+ class InspectionError < StandardError; end
11
+
12
+ Result = Struct.new(:method_names, :warning, keyword_init: true)
13
+
14
+ def initialize(ability_file:, ability_class_name: "Ability")
15
+ @ability_file = ability_file
16
+ @ability_class_name = ability_class_name
17
+ end
18
+
19
+ def call
20
+ program_node = parse_ability_file
21
+ class_node = require_class_node(program_node)
22
+ init_node = require_init_node(class_node)
23
+ user_param = require_user_param(init_node)
24
+
25
+ visitor = UserCallVisitor.new(user_param)
26
+ visitor.visit(init_node.body)
27
+
28
+ Result.new(
29
+ method_names: visitor.methods.sort.uniq,
30
+ warning: visitor.block_or_proc? ? block_or_proc_warning : nil
31
+ )
32
+ end
33
+
34
+ private
35
+
36
+ def parse_ability_file
37
+ raise InspectionError, "No such file: #{@ability_file}" unless File.exist?(@ability_file)
38
+
39
+ parse_result = Prism.parse(File.read(@ability_file))
40
+ return parse_result.value unless parse_result.failure?
41
+
42
+ messages = parse_result.errors.map(&:message).join("; ")
43
+ raise InspectionError, "Failed to parse #{@ability_file}: #{messages}"
44
+ end
45
+
46
+ def require_class_node(program_node)
47
+ find_class_node(program_node) ||
48
+ raise(InspectionError, "Could not find `class #{@ability_class_name}` in #{@ability_file}")
49
+ end
50
+
51
+ def require_init_node(class_node)
52
+ find_method_node(class_node, "initialize") ||
53
+ raise(InspectionError, "#{@ability_class_name}#initialize not found in #{@ability_file}")
54
+ end
55
+
56
+ def require_user_param(init_node)
57
+ required = init_node.parameters&.requireds&.first
58
+ required&.name&.to_s ||
59
+ raise(InspectionError, "#{@ability_class_name}#initialize takes no parameters to inspect")
60
+ end
61
+
62
+ def block_or_proc_warning
63
+ "#{@ability_class_name}#initialize contains a block or proc (e.g. `each { ... }`, `-> { ... }`) -- " \
64
+ "calls on `user` inside it were still counted, but static analysis can't guarantee this list is " \
65
+ "complete for arbitrary control flow. Review #initialize by hand too."
66
+ end
67
+
68
+ def find_class_node(node)
69
+ return nil unless node
70
+
71
+ finder = ClassNodeFinder.new(@ability_class_name)
72
+ finder.visit(node)
73
+ finder.found
74
+ end
75
+
76
+ def find_method_node(class_node, method_name)
77
+ finder = DefNodeFinder.new(method_name)
78
+ finder.visit(class_node)
79
+ finder.found
80
+ end
81
+
82
+ # Finds the named class's node in a parsed source tree.
83
+ class ClassNodeFinder < Prism::Visitor
84
+ attr_reader :found
85
+
86
+ def initialize(class_name)
87
+ super()
88
+ @class_name = class_name
89
+ end
90
+
91
+ def visit_class_node(node)
92
+ @found ||= node if node.constant_path.slice == @class_name
93
+ super unless @found
94
+ end
95
+ end
96
+
97
+ # Finds the named method's def node within a class node.
98
+ class DefNodeFinder < Prism::Visitor
99
+ attr_reader :found
100
+
101
+ def initialize(method_name)
102
+ super()
103
+ @method_name = method_name
104
+ end
105
+
106
+ def visit_def_node(node)
107
+ @found ||= node if node.name.to_s == @method_name
108
+ end
109
+ end
110
+
111
+ # Collects every method called on a given local variable name, and
112
+ # flags whether any of those calls happen inside a block or lambda.
113
+ class UserCallVisitor < Prism::Visitor
114
+ attr_reader :methods
115
+
116
+ def initialize(local_variable_name)
117
+ super()
118
+ @local_variable_name = local_variable_name
119
+ @methods = []
120
+ @block_or_proc = false
121
+ end
122
+
123
+ def block_or_proc?
124
+ @block_or_proc
125
+ end
126
+
127
+ def visit_call_node(node)
128
+ receiver = node.receiver
129
+ if receiver.is_a?(Prism::LocalVariableReadNode) && receiver.name.to_s == @local_variable_name
130
+ @methods << node.name.to_s
131
+ end
132
+ super
133
+ end
134
+
135
+ def visit_block_node(node)
136
+ @block_or_proc = true
137
+ super
138
+ end
139
+
140
+ def visit_lambda_node(node)
141
+ @block_or_proc = true
142
+ super
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAbilityGraph
4
+ # Diffs resolved scan results against a simple YAML policy declaration
5
+ # ("only `admin` may access `Payment` records") and flags roles that can
6
+ # actually reach something the policy doesn't expect. Policy language is
7
+ # deliberately flat -- model + action + allowed_roles, no nested logic.
8
+ class PolicyChecker
9
+ # unmatched: policies whose model/action matched zero scan results (e.g. a
10
+ # typo) -- kept separate from violations so that case can't read as "clean".
11
+ Report = Struct.new(:violations, :unmatched, keyword_init: true)
12
+
13
+ def self.call(policies:, results:)
14
+ new(policies: policies, results: results).call
15
+ end
16
+
17
+ def initialize(policies:, results:)
18
+ @policies = policies
19
+ @results = results
20
+ end
21
+
22
+ def call
23
+ violations = []
24
+ unmatched = []
25
+
26
+ @policies.each do |policy|
27
+ matches = matching_results(policy)
28
+ matches.empty? ? unmatched << policy : violations.concat(violations_for(policy, matches))
29
+ end
30
+
31
+ Report.new(violations: violations, unmatched: unmatched)
32
+ end
33
+
34
+ private
35
+
36
+ def matching_results(policy)
37
+ model = policy["model"].to_s
38
+ action = policy["action"].to_s
39
+ @results.select { |r| r["model"] == model && r["action"] == action }
40
+ end
41
+
42
+ def violations_for(policy, matches)
43
+ allowed_roles = Array(policy["allowed_roles"]).map(&:to_s)
44
+ matches.select { |r| r["allowed"] && !allowed_roles.include?(r["role"]) }
45
+ .map { |r| build_violation(r, allowed_roles) }
46
+ end
47
+
48
+ def build_violation(result, allowed_roles)
49
+ {
50
+ "model" => result["model"],
51
+ "action" => result["action"],
52
+ "role" => result["role"],
53
+ "allowed_roles" => allowed_roles,
54
+ "confidence" => result["confidence"],
55
+ "condition" => result["condition"]
56
+ }
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAbilityGraph
4
+ # A duck-typed user stand-in, built from a role's attributes hash, that
5
+ # answers whatever methods an Ability class calls on `user`.
6
+ class RoleStandIn
7
+ def initialize(attributes = {})
8
+ @attributes = attributes.transform_keys(&:to_sym).transform_values { |v| wrap(v) }
9
+ end
10
+
11
+ def method_missing(name, *args)
12
+ @attributes.key?(name) ? @attributes[name] : super
13
+ end
14
+
15
+ def respond_to_missing?(name, include_private = false)
16
+ @attributes.key?(name) || super
17
+ end
18
+
19
+ private
20
+
21
+ # Recurses into nested Hashes (and Arrays of them) so an Ability class
22
+ # that calls a method on an *associated* stand-in -- e.g.
23
+ # `user.valuator.can_edit_dossier?`, a common real-world CanCanCan
24
+ # pattern -- gets another duck-typed stand-in back, not a plain Hash
25
+ # that doesn't respond to it.
26
+ def wrap(value)
27
+ case value
28
+ when Hash then self.class.new(value)
29
+ when Array then value.map { |v| wrap(v) }
30
+ else value
31
+ end
32
+ end
33
+ end
34
+ end