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.
- checksums.yaml +7 -0
- data/LICENSE.txt +661 -0
- data/README.md +139 -0
- data/exe/ruby-ability-graph +6 -0
- data/lib/ruby_ability_graph/cli.rb +160 -0
- data/lib/ruby_ability_graph/enumerator.rb +250 -0
- data/lib/ruby_ability_graph/harness.rb +130 -0
- data/lib/ruby_ability_graph/html_report.rb +452 -0
- data/lib/ruby_ability_graph/inspector.rb +146 -0
- data/lib/ruby_ability_graph/policy_checker.rb +59 -0
- data/lib/ruby_ability_graph/role_stand_in.rb +34 -0
- data/lib/ruby_ability_graph/rule_classifier.rb +71 -0
- data/lib/ruby_ability_graph/scan_options.rb +132 -0
- data/lib/ruby_ability_graph/scan_presenter.rb +74 -0
- data/lib/ruby_ability_graph/table_formatter.rb +64 -0
- data/lib/ruby_ability_graph/terminal_safe.rb +12 -0
- data/lib/ruby_ability_graph/version.rb +5 -0
- data/lib/ruby_ability_graph.rb +19 -0
- metadata +78 -0
|
@@ -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 → action → 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 × action × 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
|