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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +177 -0
- data/exe/spur +6 -0
- data/lib/CLAUDE.md +11 -0
- data/lib/spurline/CLAUDE.md +16 -0
- data/lib/spurline/adapters/CLAUDE.md +12 -0
- data/lib/spurline/adapters/base.rb +17 -0
- data/lib/spurline/adapters/claude.rb +208 -0
- data/lib/spurline/adapters/open_ai.rb +213 -0
- data/lib/spurline/adapters/registry.rb +33 -0
- data/lib/spurline/adapters/scheduler/base.rb +15 -0
- data/lib/spurline/adapters/scheduler/sync.rb +15 -0
- data/lib/spurline/adapters/stub_adapter.rb +54 -0
- data/lib/spurline/agent.rb +433 -0
- data/lib/spurline/audit/log.rb +156 -0
- data/lib/spurline/audit/secret_filter.rb +121 -0
- data/lib/spurline/base.rb +130 -0
- data/lib/spurline/cartographer/CLAUDE.md +12 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -0
- data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
- data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
- data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
- data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
- data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
- data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
- data/lib/spurline/cartographer/repo_profile.rb +140 -0
- data/lib/spurline/cartographer/runner.rb +88 -0
- data/lib/spurline/cartographer.rb +6 -0
- data/lib/spurline/channels/base.rb +41 -0
- data/lib/spurline/channels/event.rb +136 -0
- data/lib/spurline/channels/github.rb +205 -0
- data/lib/spurline/channels/router.rb +103 -0
- data/lib/spurline/cli/check.rb +88 -0
- data/lib/spurline/cli/checks/CLAUDE.md +11 -0
- data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
- data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
- data/lib/spurline/cli/checks/base.rb +35 -0
- data/lib/spurline/cli/checks/credentials.rb +43 -0
- data/lib/spurline/cli/checks/permissions.rb +22 -0
- data/lib/spurline/cli/checks/project_structure.rb +48 -0
- data/lib/spurline/cli/checks/session_store.rb +97 -0
- data/lib/spurline/cli/console.rb +73 -0
- data/lib/spurline/cli/credentials.rb +181 -0
- data/lib/spurline/cli/generators/CLAUDE.md +11 -0
- data/lib/spurline/cli/generators/agent.rb +123 -0
- data/lib/spurline/cli/generators/migration.rb +62 -0
- data/lib/spurline/cli/generators/project.rb +331 -0
- data/lib/spurline/cli/generators/tool.rb +98 -0
- data/lib/spurline/cli/router.rb +121 -0
- data/lib/spurline/configuration.rb +23 -0
- data/lib/spurline/dsl/CLAUDE.md +11 -0
- data/lib/spurline/dsl/guardrails.rb +108 -0
- data/lib/spurline/dsl/hooks.rb +51 -0
- data/lib/spurline/dsl/memory.rb +39 -0
- data/lib/spurline/dsl/model.rb +23 -0
- data/lib/spurline/dsl/persona.rb +74 -0
- data/lib/spurline/dsl/suspend_until.rb +53 -0
- data/lib/spurline/dsl/tools.rb +176 -0
- data/lib/spurline/errors.rb +109 -0
- data/lib/spurline/lifecycle/CLAUDE.md +18 -0
- data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
- data/lib/spurline/lifecycle/runner.rb +456 -0
- data/lib/spurline/lifecycle/states.rb +47 -0
- data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
- data/lib/spurline/memory/CLAUDE.md +12 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/CLAUDE.md +11 -0
- data/lib/spurline/memory/embedder/base.rb +17 -0
- data/lib/spurline/memory/embedder/open_ai.rb +70 -0
- data/lib/spurline/memory/episode.rb +56 -0
- data/lib/spurline/memory/episodic_store.rb +147 -0
- data/lib/spurline/memory/long_term/CLAUDE.md +11 -0
- data/lib/spurline/memory/long_term/base.rb +22 -0
- data/lib/spurline/memory/long_term/postgres.rb +106 -0
- data/lib/spurline/memory/manager.rb +147 -0
- data/lib/spurline/memory/short_term.rb +57 -0
- data/lib/spurline/orchestration/agent_spawner.rb +151 -0
- data/lib/spurline/orchestration/judge.rb +109 -0
- data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
- data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
- data/lib/spurline/orchestration/ledger.rb +339 -0
- data/lib/spurline/orchestration/merge_queue.rb +133 -0
- data/lib/spurline/orchestration/permission_intersection.rb +151 -0
- data/lib/spurline/orchestration/task_envelope.rb +201 -0
- data/lib/spurline/persona/base.rb +42 -0
- data/lib/spurline/persona/registry.rb +42 -0
- data/lib/spurline/secrets/resolver.rb +65 -0
- data/lib/spurline/secrets/vault.rb +42 -0
- data/lib/spurline/security/content.rb +76 -0
- data/lib/spurline/security/context_pipeline.rb +58 -0
- data/lib/spurline/security/gates/base.rb +36 -0
- data/lib/spurline/security/gates/operator_config.rb +22 -0
- data/lib/spurline/security/gates/system_prompt.rb +23 -0
- data/lib/spurline/security/gates/tool_result.rb +23 -0
- data/lib/spurline/security/gates/user_input.rb +22 -0
- data/lib/spurline/security/injection_scanner.rb +109 -0
- data/lib/spurline/security/pii_filter.rb +104 -0
- data/lib/spurline/session/CLAUDE.md +11 -0
- data/lib/spurline/session/resumption.rb +36 -0
- data/lib/spurline/session/serializer.rb +169 -0
- data/lib/spurline/session/session.rb +154 -0
- data/lib/spurline/session/store/CLAUDE.md +12 -0
- data/lib/spurline/session/store/base.rb +27 -0
- data/lib/spurline/session/store/memory.rb +45 -0
- data/lib/spurline/session/store/postgres.rb +123 -0
- data/lib/spurline/session/store/sqlite.rb +139 -0
- data/lib/spurline/session/suspension.rb +93 -0
- data/lib/spurline/session/turn.rb +98 -0
- data/lib/spurline/spur.rb +213 -0
- data/lib/spurline/streaming/CLAUDE.md +12 -0
- data/lib/spurline/streaming/buffer.rb +77 -0
- data/lib/spurline/streaming/chunk.rb +62 -0
- data/lib/spurline/streaming/stream_enumerator.rb +29 -0
- data/lib/spurline/testing.rb +245 -0
- data/lib/spurline/toolkit.rb +110 -0
- data/lib/spurline/tools/base.rb +209 -0
- data/lib/spurline/tools/idempotency.rb +220 -0
- data/lib/spurline/tools/permissions.rb +44 -0
- data/lib/spurline/tools/registry.rb +43 -0
- data/lib/spurline/tools/runner.rb +255 -0
- data/lib/spurline/tools/scope.rb +309 -0
- data/lib/spurline/tools/toolkit_registry.rb +63 -0
- data/lib/spurline/version.rb +5 -0
- data/lib/spurline.rb +56 -0
- 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
|