spurline-core 0.3.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 (127) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +177 -0
  4. data/exe/spur +6 -0
  5. data/lib/CLAUDE.md +11 -0
  6. data/lib/spurline/CLAUDE.md +16 -0
  7. data/lib/spurline/adapters/CLAUDE.md +12 -0
  8. data/lib/spurline/adapters/base.rb +17 -0
  9. data/lib/spurline/adapters/claude.rb +208 -0
  10. data/lib/spurline/adapters/open_ai.rb +213 -0
  11. data/lib/spurline/adapters/registry.rb +33 -0
  12. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  13. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  14. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  15. data/lib/spurline/agent.rb +433 -0
  16. data/lib/spurline/audit/log.rb +156 -0
  17. data/lib/spurline/audit/secret_filter.rb +121 -0
  18. data/lib/spurline/base.rb +130 -0
  19. data/lib/spurline/cartographer/CLAUDE.md +12 -0
  20. data/lib/spurline/cartographer/analyzer.rb +71 -0
  21. data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -0
  22. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  23. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  24. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  25. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  26. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  27. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  28. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  29. data/lib/spurline/cartographer/runner.rb +88 -0
  30. data/lib/spurline/cartographer.rb +6 -0
  31. data/lib/spurline/channels/base.rb +41 -0
  32. data/lib/spurline/channels/event.rb +136 -0
  33. data/lib/spurline/channels/github.rb +205 -0
  34. data/lib/spurline/channels/router.rb +103 -0
  35. data/lib/spurline/cli/check.rb +88 -0
  36. data/lib/spurline/cli/checks/CLAUDE.md +11 -0
  37. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  38. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  39. data/lib/spurline/cli/checks/base.rb +35 -0
  40. data/lib/spurline/cli/checks/credentials.rb +43 -0
  41. data/lib/spurline/cli/checks/permissions.rb +22 -0
  42. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  43. data/lib/spurline/cli/checks/session_store.rb +97 -0
  44. data/lib/spurline/cli/console.rb +73 -0
  45. data/lib/spurline/cli/credentials.rb +181 -0
  46. data/lib/spurline/cli/generators/CLAUDE.md +11 -0
  47. data/lib/spurline/cli/generators/agent.rb +123 -0
  48. data/lib/spurline/cli/generators/migration.rb +62 -0
  49. data/lib/spurline/cli/generators/project.rb +331 -0
  50. data/lib/spurline/cli/generators/tool.rb +98 -0
  51. data/lib/spurline/cli/router.rb +121 -0
  52. data/lib/spurline/configuration.rb +23 -0
  53. data/lib/spurline/dsl/CLAUDE.md +11 -0
  54. data/lib/spurline/dsl/guardrails.rb +108 -0
  55. data/lib/spurline/dsl/hooks.rb +51 -0
  56. data/lib/spurline/dsl/memory.rb +39 -0
  57. data/lib/spurline/dsl/model.rb +23 -0
  58. data/lib/spurline/dsl/persona.rb +74 -0
  59. data/lib/spurline/dsl/suspend_until.rb +53 -0
  60. data/lib/spurline/dsl/tools.rb +176 -0
  61. data/lib/spurline/errors.rb +109 -0
  62. data/lib/spurline/lifecycle/CLAUDE.md +18 -0
  63. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  64. data/lib/spurline/lifecycle/runner.rb +456 -0
  65. data/lib/spurline/lifecycle/states.rb +47 -0
  66. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  67. data/lib/spurline/memory/CLAUDE.md +12 -0
  68. data/lib/spurline/memory/context_assembler.rb +100 -0
  69. data/lib/spurline/memory/embedder/CLAUDE.md +11 -0
  70. data/lib/spurline/memory/embedder/base.rb +17 -0
  71. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  72. data/lib/spurline/memory/episode.rb +56 -0
  73. data/lib/spurline/memory/episodic_store.rb +147 -0
  74. data/lib/spurline/memory/long_term/CLAUDE.md +11 -0
  75. data/lib/spurline/memory/long_term/base.rb +22 -0
  76. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  77. data/lib/spurline/memory/manager.rb +147 -0
  78. data/lib/spurline/memory/short_term.rb +57 -0
  79. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  80. data/lib/spurline/orchestration/judge.rb +109 -0
  81. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  82. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  83. data/lib/spurline/orchestration/ledger.rb +339 -0
  84. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  85. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  86. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  87. data/lib/spurline/persona/base.rb +42 -0
  88. data/lib/spurline/persona/registry.rb +42 -0
  89. data/lib/spurline/secrets/resolver.rb +65 -0
  90. data/lib/spurline/secrets/vault.rb +42 -0
  91. data/lib/spurline/security/content.rb +76 -0
  92. data/lib/spurline/security/context_pipeline.rb +58 -0
  93. data/lib/spurline/security/gates/base.rb +36 -0
  94. data/lib/spurline/security/gates/operator_config.rb +22 -0
  95. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  96. data/lib/spurline/security/gates/tool_result.rb +23 -0
  97. data/lib/spurline/security/gates/user_input.rb +22 -0
  98. data/lib/spurline/security/injection_scanner.rb +109 -0
  99. data/lib/spurline/security/pii_filter.rb +104 -0
  100. data/lib/spurline/session/CLAUDE.md +11 -0
  101. data/lib/spurline/session/resumption.rb +36 -0
  102. data/lib/spurline/session/serializer.rb +169 -0
  103. data/lib/spurline/session/session.rb +154 -0
  104. data/lib/spurline/session/store/CLAUDE.md +12 -0
  105. data/lib/spurline/session/store/base.rb +27 -0
  106. data/lib/spurline/session/store/memory.rb +45 -0
  107. data/lib/spurline/session/store/postgres.rb +123 -0
  108. data/lib/spurline/session/store/sqlite.rb +139 -0
  109. data/lib/spurline/session/suspension.rb +93 -0
  110. data/lib/spurline/session/turn.rb +98 -0
  111. data/lib/spurline/spur.rb +213 -0
  112. data/lib/spurline/streaming/CLAUDE.md +12 -0
  113. data/lib/spurline/streaming/buffer.rb +77 -0
  114. data/lib/spurline/streaming/chunk.rb +62 -0
  115. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  116. data/lib/spurline/testing.rb +245 -0
  117. data/lib/spurline/toolkit.rb +110 -0
  118. data/lib/spurline/tools/base.rb +209 -0
  119. data/lib/spurline/tools/idempotency.rb +220 -0
  120. data/lib/spurline/tools/permissions.rb +44 -0
  121. data/lib/spurline/tools/registry.rb +43 -0
  122. data/lib/spurline/tools/runner.rb +255 -0
  123. data/lib/spurline/tools/scope.rb +309 -0
  124. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  125. data/lib/spurline/version.rb +5 -0
  126. data/lib/spurline.rb +56 -0
  127. metadata +333 -0
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "set"
5
+
6
+ module Spurline
7
+ module Cartographer
8
+ module Analyzers
9
+ class EntryPoints < Analyzer
10
+ def analyze
11
+ grouped = {
12
+ web: Set.new,
13
+ background: Set.new,
14
+ console: Set.new,
15
+ test: Set.new,
16
+ lint: Set.new,
17
+ deploy: Set.new,
18
+ }
19
+
20
+ collect_executables(grouped)
21
+ collect_procfile(grouped)
22
+ collect_makefile(grouped)
23
+ collect_package_scripts(grouped)
24
+ collect_rakefile(grouped)
25
+
26
+ @findings = {
27
+ entry_points: grouped.transform_values { |commands| commands.to_a.sort },
28
+ }
29
+ end
30
+
31
+ def confidence
32
+ commands = findings[:entry_points].values.flatten
33
+ commands.empty? ? 0.5 : 0.9
34
+ end
35
+
36
+ private
37
+
38
+ def collect_executables(grouped)
39
+ (glob("bin/*") + glob("exe/*")).uniq.each do |path|
40
+ next unless File.file?(path)
41
+
42
+ command = "./#{relative_path(path)}"
43
+ classify_command(grouped, File.basename(path), command)
44
+ end
45
+ end
46
+
47
+ def collect_procfile(grouped)
48
+ content = read_file("Procfile")
49
+ return unless content
50
+
51
+ content.each_line do |line|
52
+ stripped = line.strip
53
+ next if stripped.empty? || stripped.start_with?("#")
54
+
55
+ type, command = stripped.split(":", 2)
56
+ next unless type && command
57
+
58
+ normalized = command.strip
59
+ case type.strip
60
+ when "web"
61
+ grouped[:web] << normalized
62
+ when "worker", "jobs", "queue"
63
+ grouped[:background] << normalized
64
+ when "console"
65
+ grouped[:console] << normalized
66
+ when "test"
67
+ grouped[:test] << normalized
68
+ end
69
+ end
70
+ end
71
+
72
+ def collect_makefile(grouped)
73
+ content = read_file("Makefile")
74
+ return unless content
75
+
76
+ content.each_line do |line|
77
+ match = line.match(/^([A-Za-z0-9_.-]+):(?:\s|$)/)
78
+ next unless match
79
+
80
+ target = match[1]
81
+ next if target.start_with?(".") || target.include?("%")
82
+
83
+ command = "make #{target}"
84
+ classify_command(grouped, target, command)
85
+ end
86
+ end
87
+
88
+ def collect_package_scripts(grouped)
89
+ content = read_file("package.json")
90
+ return unless content
91
+
92
+ package = JSON.parse(content)
93
+ scripts = package["scripts"]
94
+ return unless scripts.is_a?(Hash)
95
+
96
+ scripts.each do |name, script|
97
+ command = script.to_s.strip
98
+ next if command.empty?
99
+
100
+ classify_command(grouped, name, command)
101
+ end
102
+ rescue JSON::ParserError
103
+ nil
104
+ end
105
+
106
+ def collect_rakefile(grouped)
107
+ content = read_file("Rakefile")
108
+ return unless content
109
+
110
+ grouped[:test] << "bundle exec rake spec" if content.match?(/RSpec::Core::RakeTask|task\s+:spec/)
111
+ grouped[:console] << "bundle exec rake -T"
112
+ end
113
+
114
+ def classify_command(grouped, name, command)
115
+ token = name.to_s.downcase
116
+ lower_command = command.downcase
117
+
118
+ if token.match?(/web|server|start|puma|rails/) || lower_command.match?(/\b(puma|rails server|rackup|npm start|node\s+)/)
119
+ grouped[:web] << command
120
+ end
121
+
122
+ if token.match?(/worker|job|queue|sidekiq|resque/) || lower_command.match?(/\b(sidekiq|resque|worker)\b/)
123
+ grouped[:background] << command
124
+ end
125
+
126
+ if token.match?(/console|repl|irb|pry/) || lower_command.match?(/\b(rails console|irb|pry)\b/)
127
+ grouped[:console] << command
128
+ end
129
+
130
+ if token.match?(/test|spec|rspec|jest|pytest/) || lower_command.match?(/\b(rspec|jest|pytest|minitest|go test|cargo test|npm test|bundle exec rspec)\b/)
131
+ grouped[:test] << command
132
+ end
133
+
134
+ if token.match?(/lint|rubocop|eslint|prettier/) || lower_command.match?(/\b(rubocop|eslint|prettier|lint)\b/)
135
+ grouped[:lint] << command
136
+ end
137
+
138
+ if token.match?(/deploy|release/) || lower_command.match?(/\b(deploy|kubectl|helm|cap\s)\b/)
139
+ grouped[:deploy] << command
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Cartographer
5
+ module Analyzers
6
+ class FileSignatures < Analyzer
7
+ LANGUAGE_SENTINELS = {
8
+ ruby: %w[Gemfile Gemfile.lock .ruby-version Rakefile],
9
+ javascript: %w[package.json .node-version .nvmrc],
10
+ python: %w[pyproject.toml .python-version requirements.txt],
11
+ go: %w[go.mod],
12
+ rust: %w[Cargo.toml],
13
+ java: %w[pom.xml],
14
+ }.freeze
15
+
16
+ TOOLCHAIN_SENTINELS = %w[Makefile docker-compose.yml Dockerfile].freeze
17
+ PRIORITY = %i[ruby javascript python go rust java].freeze
18
+
19
+ def analyze
20
+ language_scores = {}
21
+ detected = {}
22
+
23
+ LANGUAGE_SENTINELS.each do |language, files|
24
+ present = files.select { |file| file_exists?(file) }
25
+ next if present.empty?
26
+
27
+ language_scores[language] = present.length
28
+ detected[language] = present
29
+ end
30
+
31
+ ordered_languages = language_scores.keys.sort_by do |language|
32
+ [-language_scores[language], PRIORITY.index(language) || PRIORITY.length]
33
+ end
34
+
35
+ @findings = {
36
+ languages: {
37
+ primary: ordered_languages.first,
38
+ secondary: ordered_languages.drop(1),
39
+ },
40
+ metadata: {
41
+ file_signatures: {
42
+ detected: detected,
43
+ toolchain: TOOLCHAIN_SENTINELS.select { |file| file_exists?(file) },
44
+ },
45
+ },
46
+ }
47
+ end
48
+
49
+ def confidence
50
+ findings.dig(:languages, :primary).nil? ? 0.85 : 1.0
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Spurline
6
+ module Cartographer
7
+ module Analyzers
8
+ class Manifests < Analyzer
9
+ WEB_FRAMEWORKS = {
10
+ rails: "rails",
11
+ sinatra: "sinatra",
12
+ express: "express",
13
+ django: "django",
14
+ }.freeze
15
+
16
+ TEST_FRAMEWORKS = {
17
+ rspec: %w[rspec rspec-rails],
18
+ minitest: %w[minitest],
19
+ jest: %w[jest],
20
+ pytest: %w[pytest],
21
+ }.freeze
22
+
23
+ LINTERS = {
24
+ rubocop: "rubocop",
25
+ eslint: "eslint",
26
+ prettier: "prettier",
27
+ }.freeze
28
+
29
+ def analyze
30
+ gemfile = read_file("Gemfile")
31
+ gemfile_lock = read_file("Gemfile.lock")
32
+ package = parse_json_file("package.json")
33
+ pyproject = read_file("pyproject.toml")
34
+
35
+ frameworks = {}
36
+
37
+ web_name, web_version = detect_web_framework(
38
+ gemfile: gemfile,
39
+ gemfile_lock: gemfile_lock,
40
+ package: package,
41
+ pyproject: pyproject
42
+ )
43
+ frameworks[:web] = { name: web_name, version: web_version } if web_name
44
+
45
+ test_framework = detect_test_framework(
46
+ gemfile: gemfile,
47
+ gemfile_lock: gemfile_lock,
48
+ package: package,
49
+ pyproject: pyproject
50
+ )
51
+ frameworks[:test] = test_framework if test_framework
52
+
53
+ linters = detect_linters(
54
+ gemfile: gemfile,
55
+ gemfile_lock: gemfile_lock,
56
+ package: package,
57
+ pyproject: pyproject
58
+ )
59
+ frameworks[:linter] = linters.length == 1 ? linters.first : linters unless linters.empty?
60
+
61
+ ruby_version = detect_ruby_version(gemfile)
62
+ node_version = detect_node_version(package)
63
+
64
+ parsed_files = []
65
+ parsed_files << "Gemfile" if gemfile
66
+ parsed_files << "Gemfile.lock" if gemfile_lock
67
+ parsed_files << "package.json" if package
68
+ parsed_files << "pyproject.toml" if pyproject
69
+ parsed_files << ".ruby-version" if file_exists?(".ruby-version")
70
+ parsed_files << ".node-version" if file_exists?(".node-version")
71
+ parsed_files << ".python-version" if file_exists?(".python-version")
72
+
73
+ result = {
74
+ frameworks: frameworks,
75
+ metadata: {
76
+ manifests: {
77
+ parsed_files: parsed_files,
78
+ },
79
+ },
80
+ }
81
+ result[:ruby_version] = ruby_version if ruby_version
82
+ result[:node_version] = node_version if node_version
83
+
84
+ @findings = result
85
+ end
86
+
87
+ def confidence
88
+ parsed_count = findings.dig(:metadata, :manifests, :parsed_files)&.length.to_i
89
+ parsed_count.zero? ? 0.6 : 0.95
90
+ end
91
+
92
+ private
93
+
94
+ def detect_web_framework(gemfile:, gemfile_lock:, package:, pyproject:)
95
+ return [:rails, gem_version("rails", gemfile, gemfile_lock, package)] if dependency_present?("rails", gemfile, gemfile_lock, package, pyproject)
96
+ return [:sinatra, gem_version("sinatra", gemfile, gemfile_lock, package)] if dependency_present?("sinatra", gemfile, gemfile_lock, package, pyproject)
97
+ return [:express, package_version("express", package)] if dependency_present?("express", gemfile, gemfile_lock, package, pyproject)
98
+ return [:django, python_version("django", pyproject)] if dependency_present?("django", gemfile, gemfile_lock, package, pyproject)
99
+
100
+ [nil, nil]
101
+ end
102
+
103
+ def detect_test_framework(gemfile:, gemfile_lock:, package:, pyproject:)
104
+ TEST_FRAMEWORKS.each do |key, package_names|
105
+ return key if package_names.any? { |name| dependency_present?(name, gemfile, gemfile_lock, package, pyproject) }
106
+ end
107
+
108
+ nil
109
+ end
110
+
111
+ def detect_linters(gemfile:, gemfile_lock:, package:, pyproject:)
112
+ LINTERS.each_with_object([]) do |(key, package_name), list|
113
+ list << key if dependency_present?(package_name, gemfile, gemfile_lock, package, pyproject)
114
+ end
115
+ end
116
+
117
+ def detect_ruby_version(gemfile)
118
+ version_file = read_file(".ruby-version")&.strip
119
+ return version_file unless version_file.to_s.empty?
120
+
121
+ gemfile&.match(/^\s*ruby\s+["']([^"']+)["']/)&.captures&.first
122
+ end
123
+
124
+ def detect_node_version(package)
125
+ version_file = read_file(".node-version")&.strip
126
+ return version_file unless version_file.to_s.empty?
127
+
128
+ nvmrc_version = read_file(".nvmrc")&.strip
129
+ return nvmrc_version unless nvmrc_version.to_s.empty?
130
+
131
+ package&.dig("engines", "node")
132
+ end
133
+
134
+ def dependency_present?(name, gemfile, gemfile_lock, package, pyproject)
135
+ gem_declared?(name, gemfile) ||
136
+ gem_locked?(name, gemfile_lock) ||
137
+ package_dependency?(name, package) ||
138
+ python_dependency?(name, pyproject)
139
+ end
140
+
141
+ def gem_declared?(name, gemfile)
142
+ return false unless gemfile
143
+
144
+ gemfile.match?(/^\s*gem\s+["']#{Regexp.escape(name)}["']/)
145
+ end
146
+
147
+ def gem_locked?(name, gemfile_lock)
148
+ return false unless gemfile_lock
149
+
150
+ gemfile_lock.match?(/^\s{4}#{Regexp.escape(name)}\s+\(/)
151
+ end
152
+
153
+ def gem_version(name, gemfile, gemfile_lock, package)
154
+ version = gemfile_lock&.match(/^\s{4}#{Regexp.escape(name)}\s+\(([^)]+)\)/)&.captures&.first
155
+ return version if version
156
+
157
+ declared = gem_declared_version(name, gemfile)
158
+ return declared if declared
159
+
160
+ package_version(name, package)
161
+ end
162
+
163
+ def gem_declared_version(name, gemfile)
164
+ return nil unless gemfile
165
+
166
+ line = gemfile.each_line.find { |text| text.match?(/^\s*gem\s+["']#{Regexp.escape(name)}["']/) }
167
+ return nil unless line
168
+
169
+ match = line.match(/^\s*gem\s+["']#{Regexp.escape(name)}["']\s*,\s*["']([^"']+)["']/)
170
+ return nil unless match
171
+
172
+ normalize_version_requirement(match[1])
173
+ end
174
+
175
+ def normalize_version_requirement(value)
176
+ token = value.to_s.split(",").first.to_s.strip
177
+ token = token.sub(/\A[~><=\s]*/, "")
178
+ token.empty? ? nil : token
179
+ end
180
+
181
+ def package_dependency?(name, package)
182
+ return false unless package.is_a?(Hash)
183
+
184
+ package.key?("dependencies") && package["dependencies"].is_a?(Hash) && package["dependencies"].key?(name) ||
185
+ package.key?("devDependencies") && package["devDependencies"].is_a?(Hash) && package["devDependencies"].key?(name)
186
+ end
187
+
188
+ def package_version(name, package)
189
+ return nil unless package.is_a?(Hash)
190
+
191
+ package.dig("dependencies", name) || package.dig("devDependencies", name)
192
+ end
193
+
194
+ def python_dependency?(name, pyproject)
195
+ return false unless pyproject
196
+
197
+ pyproject.match?(/#{Regexp.escape(name)}\s*(?:[<>=~!]|$)/i)
198
+ end
199
+
200
+ def python_version(name, pyproject)
201
+ return nil unless pyproject
202
+
203
+ pyproject.match(/#{Regexp.escape(name)}\s*([<>=~!].+?)?(?:\n|$)/i)&.captures&.first&.strip
204
+ end
205
+
206
+ def parse_json_file(relative_path)
207
+ content = read_file(relative_path)
208
+ return nil unless content
209
+
210
+ JSON.parse(content)
211
+ rescue JSON::ParserError
212
+ nil
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "find"
4
+ require "json"
5
+
6
+ module Spurline
7
+ module Cartographer
8
+ module Analyzers
9
+ class SecurityScan < Analyzer
10
+ SECRET_PATTERNS = {
11
+ openai_key: /\bsk-[A-Za-z0-9]{20,}\b/,
12
+ aws_access_key_id: /\bAKIA[0-9A-Z]{16}\b/,
13
+ github_token: /\bghp_[A-Za-z0-9]{36}\b/,
14
+ }.freeze
15
+ GENERIC_ASSIGNMENT_PATTERN = /\b(api[_-]?key|token|password|secret)\b\s*[:=]\s*["']([^"']{16,})["']/i.freeze
16
+
17
+ CURATED_SUSPICIOUS_DEPENDENCIES = %w[
18
+ pymafka
19
+ requests-darwin
20
+ osxroot
21
+ colourfool
22
+ ].freeze
23
+
24
+ MAX_SCAN_BYTES = 512_000
25
+
26
+ def analyze
27
+ findings = []
28
+ findings.concat(scan_sensitive_filenames)
29
+ findings.concat(scan_secret_patterns)
30
+ findings.concat(scan_suspicious_dependencies)
31
+
32
+ @findings = {
33
+ security_findings: findings,
34
+ }
35
+ end
36
+
37
+ def confidence
38
+ 0.9
39
+ end
40
+
41
+ private
42
+
43
+ def scan_sensitive_filenames
44
+ each_candidate_file.each_with_object([]) do |(path, rel_path), findings|
45
+ basename = File.basename(path)
46
+
47
+ if rel_path == ".env" || rel_path.end_with?("/.env")
48
+ findings << finding(
49
+ type: :sensitive_file,
50
+ severity: :high,
51
+ file: rel_path,
52
+ detail: "Environment file is committed."
53
+ )
54
+ end
55
+
56
+ if rel_path == "config/credentials.yml" || rel_path == "credentials.yml"
57
+ findings << finding(
58
+ type: :sensitive_file,
59
+ severity: :high,
60
+ file: rel_path,
61
+ detail: "Credentials file is committed."
62
+ )
63
+ end
64
+
65
+ if basename.match?(/\.(pem|key)\z/i)
66
+ findings << finding(
67
+ type: :sensitive_file,
68
+ severity: :high,
69
+ file: rel_path,
70
+ detail: "Private key material appears committed (#{basename})."
71
+ )
72
+ end
73
+ end
74
+ end
75
+
76
+ def scan_secret_patterns
77
+ each_candidate_file.each_with_object([]) do |(path, rel_path), findings|
78
+ next if binary_file?(path)
79
+
80
+ content = read_limited(path)
81
+ next if content.nil? || content.empty?
82
+
83
+ SECRET_PATTERNS.each do |name, pattern|
84
+ next unless content.match?(pattern)
85
+
86
+ findings << finding(
87
+ type: :hardcoded_secret,
88
+ severity: :high,
89
+ file: rel_path,
90
+ detail: "Matched #{name} pattern."
91
+ )
92
+ end
93
+
94
+ generic_secret_assignment_details(content).each do |detail|
95
+ findings << finding(
96
+ type: :hardcoded_secret,
97
+ severity: :high,
98
+ file: rel_path,
99
+ detail: detail
100
+ )
101
+ end
102
+ end
103
+ end
104
+
105
+ def scan_suspicious_dependencies
106
+ findings = []
107
+
108
+ package = parse_json_file("package.json")
109
+ if package
110
+ deps = [package["dependencies"], package["devDependencies"]].compact
111
+ .select { |value| value.is_a?(Hash) }
112
+ .flat_map(&:keys)
113
+ .uniq
114
+
115
+ deps.each do |dependency|
116
+ next unless CURATED_SUSPICIOUS_DEPENDENCIES.include?(dependency)
117
+
118
+ findings << finding(
119
+ type: :suspicious_dependency,
120
+ severity: :medium,
121
+ file: "package.json",
122
+ detail: "Dependency '#{dependency}' is in the curated suspicious list."
123
+ )
124
+ end
125
+ end
126
+
127
+ gemfile = read_file("Gemfile")
128
+ if gemfile
129
+ gemfile.scan(/^\s*gem\s+["']([^"']+)["']/).flatten.each do |dependency|
130
+ next unless CURATED_SUSPICIOUS_DEPENDENCIES.include?(dependency)
131
+
132
+ findings << finding(
133
+ type: :suspicious_dependency,
134
+ severity: :medium,
135
+ file: "Gemfile",
136
+ detail: "Gem '#{dependency}' is in the curated suspicious list."
137
+ )
138
+ end
139
+ end
140
+
141
+ findings
142
+ end
143
+
144
+ def each_candidate_file
145
+ files = []
146
+
147
+ Find.find(repo_path) do |path|
148
+ rel_path = relative_path(path)
149
+ rel_path = "." if rel_path.empty?
150
+
151
+ if File.directory?(path)
152
+ if rel_path != "." && excluded_relative_path?(rel_path)
153
+ Find.prune
154
+ else
155
+ next
156
+ end
157
+ end
158
+
159
+ next if excluded_relative_path?(rel_path)
160
+ next unless File.file?(path)
161
+
162
+ files << [path, rel_path]
163
+ end
164
+
165
+ files
166
+ end
167
+
168
+ def parse_json_file(relative_path)
169
+ content = read_file(relative_path)
170
+ return nil unless content
171
+
172
+ JSON.parse(content)
173
+ rescue JSON::ParserError
174
+ nil
175
+ end
176
+
177
+ def read_limited(path)
178
+ File.open(path, "rb") { |file| file.read(MAX_SCAN_BYTES) }
179
+ &.force_encoding(Encoding::UTF_8)
180
+ &.scrub
181
+ rescue Errno::ENOENT, ArgumentError
182
+ nil
183
+ end
184
+
185
+ def binary_file?(path)
186
+ sample = File.open(path, "rb") { |file| file.read(1024) }
187
+ return false unless sample
188
+
189
+ sample.include?("\x00")
190
+ rescue Errno::ENOENT
191
+ false
192
+ end
193
+
194
+ def finding(type:, severity:, file:, detail:)
195
+ {
196
+ type: type,
197
+ severity: severity,
198
+ file: file,
199
+ detail: detail,
200
+ }
201
+ end
202
+
203
+ def generic_secret_assignment_details(content)
204
+ content.scan(GENERIC_ASSIGNMENT_PATTERN).filter_map do |(name, value)|
205
+ next if benign_secret_value?(value)
206
+
207
+ "Matched generic_secret_assignment pattern for #{name.downcase}."
208
+ end.uniq
209
+ end
210
+
211
+ def benign_secret_value?(value)
212
+ normalized = value.to_s.strip
213
+ return true if normalized.empty?
214
+ return true if normalized.match?(/\A\[REDACTED/i)
215
+ return true if normalized.match?(/\A(redacted|test|example|dummy|fake|placeholder|changeme|replace_me)/i)
216
+ return true if normalized.include?("user@example.com")
217
+
218
+ false
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end