rails-doctor 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/CHANGELOG.md +11 -0
- data/LICENSE +21 -0
- data/README.md +148 -0
- data/docs/adapter-architecture.md +21 -0
- data/docs/agent-handoff.md +22 -0
- data/docs/architecture.md +21 -0
- data/docs/cli-reference.md +48 -0
- data/docs/config-reference.md +50 -0
- data/docs/github-actions.md +36 -0
- data/docs/monetization.md +14 -0
- data/docs/output-schema.md +57 -0
- data/docs/scoring-model.md +23 -0
- data/examples/github-actions/rails-doctor.yml +40 -0
- data/examples/report.html +704 -0
- data/examples/report.json +971 -0
- data/examples/report.md +261 -0
- data/examples/report.txt +45 -0
- data/exe/rails-doctor +8 -0
- data/lib/rails_doctor/adapters/base.rb +109 -0
- data/lib/rails_doctor/adapters/brakeman.rb +47 -0
- data/lib/rails_doctor/adapters/bundler_audit.rb +54 -0
- data/lib/rails_doctor/adapters/dependency_freshness.rb +51 -0
- data/lib/rails_doctor/adapters/flay.rb +41 -0
- data/lib/rails_doctor/adapters/flog.rb +41 -0
- data/lib/rails_doctor/adapters/reek.rb +39 -0
- data/lib/rails_doctor/adapters/rubocop.rb +40 -0
- data/lib/rails_doctor/adapters/strong_migrations.rb +52 -0
- data/lib/rails_doctor/adapters/test_coverage.rb +400 -0
- data/lib/rails_doctor/adapters/test_runner.rb +79 -0
- data/lib/rails_doctor/adapters/zeitwerk.rb +42 -0
- data/lib/rails_doctor/agent/handoff.rb +159 -0
- data/lib/rails_doctor/checks/rails_checks.rb +371 -0
- data/lib/rails_doctor/cli.rb +232 -0
- data/lib/rails_doctor/command_runner.rb +55 -0
- data/lib/rails_doctor/config.rb +161 -0
- data/lib/rails_doctor/init/runner.rb +191 -0
- data/lib/rails_doctor/models.rb +280 -0
- data/lib/rails_doctor/project.rb +95 -0
- data/lib/rails_doctor/reporters/html.rb +400 -0
- data/lib/rails_doctor/reporters/json.rb +18 -0
- data/lib/rails_doctor/reporters/markdown.rb +132 -0
- data/lib/rails_doctor/reporters/terminal.rb +101 -0
- data/lib/rails_doctor/scanner.rb +173 -0
- data/lib/rails_doctor/scorer.rb +74 -0
- data/lib/rails_doctor/version.rb +5 -0
- data/lib/rails_doctor.rb +15 -0
- data/site/assets/cli-output.png +0 -0
- data/site/assets/report-preview.png +0 -0
- data/site/index.html +294 -0
- metadata +167 -0
data/examples/report.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# Rails Doctor Report
|
|
2
|
+
|
|
3
|
+
- Profile: `deep`
|
|
4
|
+
- Overall score: `0/100`
|
|
5
|
+
- Changed-files score: `100/100`
|
|
6
|
+
- Confidence: `100%`
|
|
7
|
+
- Coverage: `48.00% lines`
|
|
8
|
+
- Findings: `24`
|
|
9
|
+
- Duration: `600ms`
|
|
10
|
+
|
|
11
|
+
## Coverage
|
|
12
|
+
|
|
13
|
+
- Line coverage: `48.00%`
|
|
14
|
+
- Line threshold: `90.00%`
|
|
15
|
+
- Covered lines: `12/25`
|
|
16
|
+
- Branch coverage: `50.00%`
|
|
17
|
+
|
|
18
|
+
Low-coverage files:
|
|
19
|
+
- `app/controllers/posts_controller.rb`: 22.22% lines (2/9)
|
|
20
|
+
- `app/models/post.rb`: 44.44% lines (4/9)
|
|
21
|
+
|
|
22
|
+
## Severity Breakdown
|
|
23
|
+
|
|
24
|
+
- `medium`: 10
|
|
25
|
+
- `critical`: 1
|
|
26
|
+
- `high`: 10
|
|
27
|
+
- `low`: 3
|
|
28
|
+
|
|
29
|
+
## Skipped Tools
|
|
30
|
+
|
|
31
|
+
No tools were skipped.
|
|
32
|
+
|
|
33
|
+
## Top Findings
|
|
34
|
+
|
|
35
|
+
### CRITICAL: SQL Injection: Possible SQL injection
|
|
36
|
+
|
|
37
|
+
- Tool: `brakeman`
|
|
38
|
+
- Category: `security`
|
|
39
|
+
- Location: `app/models/post.rb:8`
|
|
40
|
+
- Confidence: `high`
|
|
41
|
+
|
|
42
|
+
Review Brakeman guidance: https://brakemanscanner.org/docs/warning_types/sql_injection/
|
|
43
|
+
|
|
44
|
+
**Agent instruction:** Fix this security finding with the smallest behavior-preserving change. Prefer framework-safe APIs and add regression tests.
|
|
45
|
+
|
|
46
|
+
### HIGH: rack: Example vulnerability
|
|
47
|
+
|
|
48
|
+
- Tool: `bundler_audit`
|
|
49
|
+
- Category: `dependency-security`
|
|
50
|
+
- Location: `Gemfile.lock`
|
|
51
|
+
- Confidence: `high`
|
|
52
|
+
|
|
53
|
+
Update rack to a patched version and rerun Bundler Audit.
|
|
54
|
+
|
|
55
|
+
**Agent instruction:** Update the vulnerable gem conservatively, refresh the lockfile, and run the test suite.
|
|
56
|
+
|
|
57
|
+
### HIGH: Prosopite: N+1 queries detected for Post => [:user]
|
|
58
|
+
|
|
59
|
+
- Tool: `test_runner`
|
|
60
|
+
- Category: `runtime-n-plus-one`
|
|
61
|
+
- Confidence: `medium`
|
|
62
|
+
|
|
63
|
+
Fix the N+1 query by eager loading or adjusting the query path exercised by tests.
|
|
64
|
+
|
|
65
|
+
**Agent instruction:** Use includes/preload/eager_load or query restructuring. Verify with the same test command.
|
|
66
|
+
|
|
67
|
+
### HIGH: posts.user_id has no index
|
|
68
|
+
|
|
69
|
+
- Tool: `rails_checks`
|
|
70
|
+
- Category: `database-integrity`
|
|
71
|
+
- Location: `db/schema.rb`
|
|
72
|
+
- Confidence: `high`
|
|
73
|
+
|
|
74
|
+
Add an index for the foreign key column to avoid slow association lookups.
|
|
75
|
+
|
|
76
|
+
**Agent instruction:** Create a migration that adds an index on posts.user_id. For PostgreSQL production apps, prefer a concurrent index path compatible with strong_migrations.
|
|
77
|
+
|
|
78
|
+
### HIGH: users.email has a Rails uniqueness validation without a unique database index
|
|
79
|
+
|
|
80
|
+
- Tool: `rails_checks`
|
|
81
|
+
- Category: `database-integrity`
|
|
82
|
+
- Location: `app/models/user.rb`
|
|
83
|
+
- Confidence: `medium`
|
|
84
|
+
|
|
85
|
+
Back uniqueness validations with a unique index to prevent race-condition duplicates.
|
|
86
|
+
|
|
87
|
+
**Agent instruction:** Add a unique index migration for users.email, handle existing duplicate data if necessary, and rerun tests.
|
|
88
|
+
|
|
89
|
+
### HIGH: Routes reference missing ghosts_controller.rb
|
|
90
|
+
|
|
91
|
+
- Tool: `rails_checks`
|
|
92
|
+
- Category: `routing`
|
|
93
|
+
- Location: `config/routes.rb`
|
|
94
|
+
- Confidence: `high`
|
|
95
|
+
|
|
96
|
+
Create the controller or remove/rename the route.
|
|
97
|
+
|
|
98
|
+
**Agent instruction:** Align routes with real controller names. Prefer removing stale routes over creating empty controllers.
|
|
99
|
+
|
|
100
|
+
### HIGH: Route points to missing posts#create
|
|
101
|
+
|
|
102
|
+
- Tool: `rails_checks`
|
|
103
|
+
- Category: `routing`
|
|
104
|
+
- Location: `app/controllers/posts_controller.rb`
|
|
105
|
+
- Confidence: `high`
|
|
106
|
+
|
|
107
|
+
Implement the action or update/remove the route.
|
|
108
|
+
|
|
109
|
+
**Agent instruction:** Do not add an empty action. Determine the intended route behavior, then implement or remove the stale route.
|
|
110
|
+
|
|
111
|
+
### HIGH: Route points to missing posts#destroy
|
|
112
|
+
|
|
113
|
+
- Tool: `rails_checks`
|
|
114
|
+
- Category: `routing`
|
|
115
|
+
- Location: `app/controllers/posts_controller.rb`
|
|
116
|
+
- Confidence: `high`
|
|
117
|
+
|
|
118
|
+
Implement the action or update/remove the route.
|
|
119
|
+
|
|
120
|
+
**Agent instruction:** Do not add an empty action. Determine the intended route behavior, then implement or remove the stale route.
|
|
121
|
+
|
|
122
|
+
### HIGH: Route points to missing posts#edit
|
|
123
|
+
|
|
124
|
+
- Tool: `rails_checks`
|
|
125
|
+
- Category: `routing`
|
|
126
|
+
- Location: `app/controllers/posts_controller.rb`
|
|
127
|
+
- Confidence: `high`
|
|
128
|
+
|
|
129
|
+
Implement the action or update/remove the route.
|
|
130
|
+
|
|
131
|
+
**Agent instruction:** Do not add an empty action. Determine the intended route behavior, then implement or remove the stale route.
|
|
132
|
+
|
|
133
|
+
### HIGH: Route points to missing posts#new
|
|
134
|
+
|
|
135
|
+
- Tool: `rails_checks`
|
|
136
|
+
- Category: `routing`
|
|
137
|
+
- Location: `app/controllers/posts_controller.rb`
|
|
138
|
+
- Confidence: `high`
|
|
139
|
+
|
|
140
|
+
Implement the action or update/remove the route.
|
|
141
|
+
|
|
142
|
+
**Agent instruction:** Do not add an empty action. Determine the intended route behavior, then implement or remove the stale route.
|
|
143
|
+
|
|
144
|
+
### HIGH: Route points to missing posts#update
|
|
145
|
+
|
|
146
|
+
- Tool: `rails_checks`
|
|
147
|
+
- Category: `routing`
|
|
148
|
+
- Location: `app/controllers/posts_controller.rb`
|
|
149
|
+
- Confidence: `high`
|
|
150
|
+
|
|
151
|
+
Implement the action or update/remove the route.
|
|
152
|
+
|
|
153
|
+
**Agent instruction:** Do not add an empty action. Determine the intended route behavior, then implement or remove the stale route.
|
|
154
|
+
|
|
155
|
+
### MEDIUM: DEPRECATION WARNING: old API is deprecated
|
|
156
|
+
|
|
157
|
+
- Tool: `test_runner`
|
|
158
|
+
- Category: `deprecation`
|
|
159
|
+
- Confidence: `medium`
|
|
160
|
+
|
|
161
|
+
Resolve deprecation warnings before framework or gem upgrades make them failures.
|
|
162
|
+
|
|
163
|
+
**Agent instruction:** Update the deprecated API usage and add a regression test when behavior could change.
|
|
164
|
+
|
|
165
|
+
### MEDIUM: Line coverage 48.00% is below the 90.00% threshold
|
|
166
|
+
|
|
167
|
+
- Tool: `test_coverage`
|
|
168
|
+
- Category: `test-coverage`
|
|
169
|
+
- Confidence: `high`
|
|
170
|
+
|
|
171
|
+
Add tests for uncovered application code, starting with the lowest-coverage files.
|
|
172
|
+
|
|
173
|
+
**Agent instruction:** Prioritize behavior tests for uncovered app/lib code. Use the coverage metadata to start with files below the configured threshold.
|
|
174
|
+
|
|
175
|
+
### MEDIUM: app/controllers/posts_controller.rb line coverage 22.22% is below the 80.00% per-file threshold
|
|
176
|
+
|
|
177
|
+
- Tool: `test_coverage`
|
|
178
|
+
- Category: `test-coverage`
|
|
179
|
+
- Location: `app/controllers/posts_controller.rb`
|
|
180
|
+
- Confidence: `high`
|
|
181
|
+
|
|
182
|
+
Add focused tests that exercise the uncovered behavior in this file.
|
|
183
|
+
|
|
184
|
+
**Agent instruction:** Add or update tests for this file before expanding the implementation. Prefer behavior-level tests that cover the missing branches or lines.
|
|
185
|
+
|
|
186
|
+
### MEDIUM: app/models/post.rb line coverage 44.44% is below the 80.00% per-file threshold
|
|
187
|
+
|
|
188
|
+
- Tool: `test_coverage`
|
|
189
|
+
- Category: `test-coverage`
|
|
190
|
+
- Location: `app/models/post.rb`
|
|
191
|
+
- Confidence: `high`
|
|
192
|
+
|
|
193
|
+
Add focused tests that exercise the uncovered behavior in this file.
|
|
194
|
+
|
|
195
|
+
**Agent instruction:** Add or update tests for this file before expanding the implementation. Prefer behavior-level tests that cover the missing branches or lines.
|
|
196
|
+
|
|
197
|
+
### MEDIUM: High complexity score 32.5 for Post#publish!
|
|
198
|
+
|
|
199
|
+
- Tool: `flog`
|
|
200
|
+
- Category: `complexity`
|
|
201
|
+
- Location: `app/models/post.rb:4`
|
|
202
|
+
- Confidence: `medium`
|
|
203
|
+
|
|
204
|
+
Extract simpler methods or objects around the complex branch.
|
|
205
|
+
|
|
206
|
+
**Agent instruction:** Reduce complexity with behavior-preserving extraction. Do not combine this with unrelated cleanup.
|
|
207
|
+
|
|
208
|
+
### MEDIUM: Similar code group 1 across app/models/post.rb:4, app/models/user.rb:2
|
|
209
|
+
|
|
210
|
+
- Tool: `flay`
|
|
211
|
+
- Category: `duplication`
|
|
212
|
+
- Location: `app/models/post.rb:4`
|
|
213
|
+
- Confidence: `medium`
|
|
214
|
+
|
|
215
|
+
Review whether this duplication is intentional. Extract shared behavior only if the abstraction is clear.
|
|
216
|
+
|
|
217
|
+
**Agent instruction:** Do not blindly abstract. Compare the duplicated code paths, preserve semantics, and add tests if extracting shared code.
|
|
218
|
+
|
|
219
|
+
### MEDIUM: 1 TODO/FIXME/HACK marker in 10 lines
|
|
220
|
+
|
|
221
|
+
- Tool: `rails_checks`
|
|
222
|
+
- Category: `technical-debt`
|
|
223
|
+
- Location: `app/models/post.rb`
|
|
224
|
+
- Confidence: `medium`
|
|
225
|
+
|
|
226
|
+
Convert stale markers into tracked work or resolve them while the context is fresh.
|
|
227
|
+
|
|
228
|
+
**Agent instruction:** Do not delete markers without addressing or preserving the underlying work item. Prefer resolving changed-file markers.
|
|
229
|
+
|
|
230
|
+
### MEDIUM: posts#show has no matching template or explicit response
|
|
231
|
+
|
|
232
|
+
- Tool: `rails_checks`
|
|
233
|
+
- Category: `routing`
|
|
234
|
+
- Location: `app/controllers/posts_controller.rb`
|
|
235
|
+
- Confidence: `medium`
|
|
236
|
+
|
|
237
|
+
Add a template or explicit render/redirect/head response.
|
|
238
|
+
|
|
239
|
+
**Agent instruction:** Inspect the action intent. Add the missing view or explicit response and cover the route with a request/controller test.
|
|
240
|
+
|
|
241
|
+
### MEDIUM: TooManyStatements: has the smell of too many statements
|
|
242
|
+
|
|
243
|
+
- Tool: `reek`
|
|
244
|
+
- Category: `code-smell`
|
|
245
|
+
- Location: `app/models/post.rb:4`
|
|
246
|
+
- Confidence: `high`
|
|
247
|
+
|
|
248
|
+
Refactor the local smell without broad behavior changes.
|
|
249
|
+
|
|
250
|
+
**Agent instruction:** Refactor only the affected method/class. Preserve public behavior and add or run tests around the changed code.
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
## Hotspots
|
|
254
|
+
|
|
255
|
+
- `app/controllers/posts_controller.rb`: score 42, 8 findings, churn 0, changed=false
|
|
256
|
+
- `app/models/post.rb`: score 33, 7 findings, churn 0, changed=false
|
|
257
|
+
- `Gemfile.lock`: score 8, 2 findings, churn 0, changed=false
|
|
258
|
+
- `db/schema.rb`: score 7, 1 findings, churn 0, changed=false
|
|
259
|
+
- `app/models/user.rb`: score 7, 1 findings, churn 0, changed=false
|
|
260
|
+
- `config/routes.rb`: score 7, 1 findings, churn 0, changed=false
|
|
261
|
+
- `config/initializers/strong_migrations.rb`: score 1, 1 findings, churn 0, changed=false
|
data/examples/report.txt
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Rails Doctor
|
|
2
|
+
============
|
|
3
|
+
Profile: deep
|
|
4
|
+
Score: 0/100 overall, 100/100 changed files
|
|
5
|
+
Confidence: 100%
|
|
6
|
+
Coverage: 48.00% lines (threshold 90.00%)
|
|
7
|
+
Findings: critical=1, high=10, medium=10, low=3, info=0
|
|
8
|
+
Duration: 600ms
|
|
9
|
+
|
|
10
|
+
Low coverage files
|
|
11
|
+
- app/controllers/posts_controller.rb: 22.22% lines
|
|
12
|
+
- app/models/post.rb: 44.44% lines
|
|
13
|
+
|
|
14
|
+
Top fixes
|
|
15
|
+
- [critical] SQL Injection: Possible SQL injection
|
|
16
|
+
app/models/post.rb:8
|
|
17
|
+
Review Brakeman guidance: https://brakemanscanner.org/docs/warning_types/sql_injection/
|
|
18
|
+
- [high] rack: Example vulnerability
|
|
19
|
+
Gemfile.lock
|
|
20
|
+
Update rack to a patched version and rerun Bundler Audit.
|
|
21
|
+
- [high] Prosopite: N+1 queries detected for Post => [:user]
|
|
22
|
+
2
|
|
23
|
+
Fix the N+1 query by eager loading or adjusting the query path exercised by tests.
|
|
24
|
+
- [high] posts.user_id has no index
|
|
25
|
+
db/schema.rb
|
|
26
|
+
Add an index for the foreign key column to avoid slow association lookups.
|
|
27
|
+
- [high] users.email has a Rails uniqueness validation without a unique database index
|
|
28
|
+
app/models/user.rb
|
|
29
|
+
Back uniqueness validations with a unique index to prevent race-condition duplicates.
|
|
30
|
+
- [high] Routes reference missing ghosts_controller.rb
|
|
31
|
+
config/routes.rb
|
|
32
|
+
Create the controller or remove/rename the route.
|
|
33
|
+
- [high] Route points to missing posts#create
|
|
34
|
+
app/controllers/posts_controller.rb
|
|
35
|
+
Implement the action or update/remove the route.
|
|
36
|
+
- [high] Route points to missing posts#destroy
|
|
37
|
+
app/controllers/posts_controller.rb
|
|
38
|
+
Implement the action or update/remove the route.
|
|
39
|
+
|
|
40
|
+
Hotspots
|
|
41
|
+
- app/controllers/posts_controller.rb: score 42, 8 findings, churn 0
|
|
42
|
+
- app/models/post.rb: score 33, 7 findings, churn 0
|
|
43
|
+
- Gemfile.lock: score 8, 2 findings, churn 0
|
|
44
|
+
- db/schema.rb: score 7, 1 findings, churn 0
|
|
45
|
+
- app/models/user.rb: score 7, 1 findings, churn 0
|
data/exe/rails-doctor
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RailsDoctor
|
|
6
|
+
module Adapters
|
|
7
|
+
class Base
|
|
8
|
+
attr_reader :project, :config, :runner, :profile, :changed_files
|
|
9
|
+
|
|
10
|
+
def initialize(project:, config:, runner:, profile:, changed_files: [])
|
|
11
|
+
@project = project
|
|
12
|
+
@config = config
|
|
13
|
+
@runner = runner
|
|
14
|
+
@profile = profile
|
|
15
|
+
@changed_files = changed_files
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def name
|
|
19
|
+
self.class::NAME
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def command
|
|
23
|
+
config.command(name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def available?
|
|
27
|
+
return false if command.to_s.strip.empty?
|
|
28
|
+
|
|
29
|
+
declared_gems.any? { |gem| project.gem_declared?(gem) } || executable_names.any? { |exe| project.command_available?(exe) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def unavailable_reason
|
|
33
|
+
"#{name} is not installed or available in this project."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def install_guidance
|
|
37
|
+
"Run rails-doctor init to add #{declared_gems.first || name} to the appropriate development/test group."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run
|
|
41
|
+
command_result = runner.run(command, timeout_seconds: timeout_seconds)
|
|
42
|
+
{
|
|
43
|
+
tool_run: ToolRun.new(
|
|
44
|
+
name: name,
|
|
45
|
+
available: true,
|
|
46
|
+
skipped: false,
|
|
47
|
+
command: command,
|
|
48
|
+
exit_status: command_result.exit_status,
|
|
49
|
+
duration_ms: command_result.duration_ms,
|
|
50
|
+
stdout: command_result.stdout,
|
|
51
|
+
stderr: command_result.stderr,
|
|
52
|
+
metadata: metadata(command_result)
|
|
53
|
+
),
|
|
54
|
+
findings: parse(command_result)
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def declared_gems
|
|
61
|
+
[]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def executable_names
|
|
65
|
+
[name]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def timeout_seconds
|
|
69
|
+
120
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def metadata(_command_result)
|
|
73
|
+
{}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_json(output)
|
|
77
|
+
JSON.parse(output.to_s)
|
|
78
|
+
rescue JSON::ParserError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def command_failed_finding(command_result, severity: "medium")
|
|
83
|
+
return nil if command_result.exit_status == 0
|
|
84
|
+
|
|
85
|
+
output = [command_result.stderr, command_result.stdout].compact.join("\n").strip
|
|
86
|
+
Finding.new(
|
|
87
|
+
severity: severity,
|
|
88
|
+
category: "tool-execution",
|
|
89
|
+
tool: name,
|
|
90
|
+
confidence: "high",
|
|
91
|
+
message: "#{name} exited with status #{command_result.exit_status}",
|
|
92
|
+
recommendation: "Run #{command.inspect} directly to inspect the underlying tool failure.",
|
|
93
|
+
agent_instruction: "Do not blindly edit app code. First determine whether #{name} failed because of configuration, missing dependencies, or a real project issue.",
|
|
94
|
+
metadata: { output_excerpt: output[0, 1_000] }
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def severity_from_tool(value, default: "medium")
|
|
99
|
+
case value.to_s.downcase
|
|
100
|
+
when "fatal", "error", "critical" then "critical"
|
|
101
|
+
when "high" then "high"
|
|
102
|
+
when "warning", "warn", "medium" then "medium"
|
|
103
|
+
when "refactor", "convention", "low", "info" then "low"
|
|
104
|
+
else default
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDoctor
|
|
4
|
+
module Adapters
|
|
5
|
+
class Brakeman < Base
|
|
6
|
+
NAME = "brakeman"
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def declared_gems
|
|
11
|
+
["brakeman"]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def parse(command_result)
|
|
15
|
+
json = parse_json(command_result.stdout)
|
|
16
|
+
return [command_failed_finding(command_result, severity: "high")].compact unless json
|
|
17
|
+
|
|
18
|
+
json.fetch("warnings", []).map do |warning|
|
|
19
|
+
confidence = warning.fetch("confidence", "Medium").downcase
|
|
20
|
+
Finding.new(
|
|
21
|
+
severity: brakeman_severity(warning),
|
|
22
|
+
category: "security",
|
|
23
|
+
tool: name,
|
|
24
|
+
file: warning["file"],
|
|
25
|
+
line: warning["line"],
|
|
26
|
+
confidence: confidence,
|
|
27
|
+
message: "#{warning["warning_type"]}: #{warning["message"]}",
|
|
28
|
+
recommendation: warning["link"] ? "Review Brakeman guidance: #{warning["link"]}" : "Review and fix the security warning.",
|
|
29
|
+
agent_instruction: "Fix this security finding with the smallest behavior-preserving change. Prefer framework-safe APIs and add regression tests.",
|
|
30
|
+
metadata: { code: warning["code"], fingerprint: warning["fingerprint"] }.compact
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def brakeman_severity(warning)
|
|
36
|
+
type = warning["warning_type"].to_s.downcase
|
|
37
|
+
return "critical" if type.match?(/sql|command|mass assignment|deserial/i)
|
|
38
|
+
|
|
39
|
+
case warning["confidence"].to_s.downcase
|
|
40
|
+
when "high" then "high"
|
|
41
|
+
when "weak" then "low"
|
|
42
|
+
else "medium"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDoctor
|
|
4
|
+
module Adapters
|
|
5
|
+
class BundlerAudit < Base
|
|
6
|
+
NAME = "bundler_audit"
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def declared_gems
|
|
11
|
+
["bundler-audit"]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def executable_names
|
|
15
|
+
["bundle-audit"]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def parse(command_result)
|
|
19
|
+
json = parse_json(command_result.stdout)
|
|
20
|
+
return [command_failed_finding(command_result, severity: "high")].compact unless json
|
|
21
|
+
|
|
22
|
+
results = json["results"] || json["vulnerabilities"] || []
|
|
23
|
+
results.map do |entry|
|
|
24
|
+
advisory = entry["advisory"] || entry
|
|
25
|
+
gem = entry["gem"] || {}
|
|
26
|
+
gem_name = gem["name"] || entry["name"] || advisory["gem"]
|
|
27
|
+
title = advisory["title"] || advisory["description"] || "Vulnerable dependency"
|
|
28
|
+
Finding.new(
|
|
29
|
+
severity: advisory_severity(advisory),
|
|
30
|
+
category: "dependency-security",
|
|
31
|
+
tool: name,
|
|
32
|
+
file: "Gemfile.lock",
|
|
33
|
+
confidence: "high",
|
|
34
|
+
message: "#{gem_name}: #{title}",
|
|
35
|
+
recommendation: "Update #{gem_name} to a patched version and rerun Bundler Audit.",
|
|
36
|
+
agent_instruction: "Update the vulnerable gem conservatively, refresh the lockfile, and run the test suite.",
|
|
37
|
+
suggested_commands: ["bundle update #{gem_name}", "bundle exec bundle-audit check"],
|
|
38
|
+
metadata: { advisory: advisory["id"] || advisory["cve"], url: advisory["url"] }.compact
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def advisory_severity(advisory)
|
|
44
|
+
criticality = advisory["criticality"].to_s.downcase
|
|
45
|
+
return "critical" if criticality == "critical"
|
|
46
|
+
return "high" if criticality == "high"
|
|
47
|
+
return "medium" if criticality == "medium"
|
|
48
|
+
|
|
49
|
+
score = advisory["cvss_v3"] || advisory["cvss"]
|
|
50
|
+
score.to_f >= 9 ? "critical" : score.to_f >= 7 ? "high" : "medium"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDoctor
|
|
4
|
+
module Adapters
|
|
5
|
+
class DependencyFreshness < Base
|
|
6
|
+
NAME = "dependency_freshness"
|
|
7
|
+
|
|
8
|
+
def available?
|
|
9
|
+
File.exist?(project.join("Gemfile.lock")) && project.command_available?("bundle")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def install_guidance
|
|
13
|
+
"Install Bundler and ensure Gemfile.lock is present before running dependency freshness checks."
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def executable_names
|
|
19
|
+
["bundle"]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def timeout_seconds
|
|
23
|
+
180
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parse(command_result)
|
|
27
|
+
return [] if command_result.exit_status == 0 && command_result.stdout.strip.empty?
|
|
28
|
+
|
|
29
|
+
lines = command_result.stdout.lines.map(&:strip).reject(&:empty?)
|
|
30
|
+
lines.each_with_object([]) do |line, findings|
|
|
31
|
+
next unless line.include?(" ")
|
|
32
|
+
|
|
33
|
+
gem_name = line.split(/[ (]/).first
|
|
34
|
+
next if gem_name.to_s.empty?
|
|
35
|
+
|
|
36
|
+
findings << Finding.new(
|
|
37
|
+
severity: "low",
|
|
38
|
+
category: "dependency-freshness",
|
|
39
|
+
tool: name,
|
|
40
|
+
file: "Gemfile.lock",
|
|
41
|
+
confidence: "medium",
|
|
42
|
+
message: "#{gem_name} appears to be outdated",
|
|
43
|
+
recommendation: "Review the update risk and update in a separate dependency-focused change.",
|
|
44
|
+
agent_instruction: "Do not batch this with feature work. Update #{gem_name} conservatively and run the full test suite.",
|
|
45
|
+
suggested_commands: ["bundle update #{gem_name}"]
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDoctor
|
|
4
|
+
module Adapters
|
|
5
|
+
class Flay < Base
|
|
6
|
+
NAME = "flay"
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def declared_gems
|
|
11
|
+
["flay"]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def parse(command_result)
|
|
15
|
+
output = command_result.stdout.to_s
|
|
16
|
+
failed = command_failed_finding(command_result, severity: "medium")
|
|
17
|
+
return [failed].compact if failed && output.strip.empty?
|
|
18
|
+
|
|
19
|
+
groups = output.split(/Similar code found in/).drop(1)
|
|
20
|
+
groups.map.with_index(1) do |group, index|
|
|
21
|
+
files = group.scan(/([A-Za-z0-9_\/.-]+\.rb):(\d+)/).map { |file, line| "#{file}:#{line}" }
|
|
22
|
+
next if files.empty?
|
|
23
|
+
|
|
24
|
+
file, line_no = files.first.split(":")
|
|
25
|
+
Finding.new(
|
|
26
|
+
severity: "medium",
|
|
27
|
+
category: "duplication",
|
|
28
|
+
tool: name,
|
|
29
|
+
file: file,
|
|
30
|
+
line: line_no.to_i,
|
|
31
|
+
confidence: "medium",
|
|
32
|
+
message: "Similar code group #{index} across #{files.uniq.first(4).join(", ")}",
|
|
33
|
+
recommendation: "Review whether this duplication is intentional. Extract shared behavior only if the abstraction is clear.",
|
|
34
|
+
agent_instruction: "Do not blindly abstract. Compare the duplicated code paths, preserve semantics, and add tests if extracting shared code.",
|
|
35
|
+
metadata: { locations: files.uniq }
|
|
36
|
+
)
|
|
37
|
+
end.compact
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDoctor
|
|
4
|
+
module Adapters
|
|
5
|
+
class Flog < Base
|
|
6
|
+
NAME = "flog"
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def declared_gems
|
|
11
|
+
["flog"]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def parse(command_result)
|
|
15
|
+
failed = command_failed_finding(command_result, severity: "medium")
|
|
16
|
+
return [failed].compact if failed && command_result.stdout.strip.empty?
|
|
17
|
+
|
|
18
|
+
threshold = config.threshold("flog_high_score").to_f
|
|
19
|
+
command_result.stdout.lines.each_with_object([]) do |line, findings|
|
|
20
|
+
next unless line =~ /^\s*(\d+(?:\.\d+)?)\s+(.+?)(?:\s+([A-Za-z0-9_\/.-]+\.rb):(\d+))?\s*$/
|
|
21
|
+
|
|
22
|
+
score = Regexp.last_match(1).to_f
|
|
23
|
+
next if score < threshold
|
|
24
|
+
|
|
25
|
+
findings << Finding.new(
|
|
26
|
+
severity: score >= threshold * 2 ? "high" : "medium",
|
|
27
|
+
category: "complexity",
|
|
28
|
+
tool: name,
|
|
29
|
+
file: Regexp.last_match(3),
|
|
30
|
+
line: Regexp.last_match(4)&.to_i,
|
|
31
|
+
confidence: "medium",
|
|
32
|
+
message: "High complexity score #{score.round(1)} for #{Regexp.last_match(2).strip}",
|
|
33
|
+
recommendation: "Extract simpler methods or objects around the complex branch.",
|
|
34
|
+
agent_instruction: "Reduce complexity with behavior-preserving extraction. Do not combine this with unrelated cleanup.",
|
|
35
|
+
metadata: { flog_score: score }
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|