sentinel-ci 0.2.0 → 1.0.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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +24 -0
- data/bin/sentinel +5 -1
- data/lib/version.rb +1 -1
- data/mcp/claude-code-config.json +17 -0
- data/mcp/server.rb +242 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4afefad30650b21315fce711323ed0d39a2046a472e5e62b081d2b4b83476cf7
|
|
4
|
+
data.tar.gz: 36fa211355533de0fe72265ef44878b0ad14604f0d309a7b00f50e55ebd59517
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 70cbe3787c1ddd1e7227beac14373b0826ea108c5b985cf16f12159c55be3f41076341d22fed0bd6845d4e75eae1fd1fd8ae16ceef31b0966b1b9cd95aaa0160
|
|
7
|
+
data.tar.gz: edf35ebd84e3c162e82be1ad57b04b0f6d25ad96f3c50201db57d49847bea87b507ef7188efd30c65a62b50e248dcf1405c6cf6aff761d5601394ea035aca867
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0 (2026-05-16)
|
|
4
|
+
|
|
5
|
+
### New Features
|
|
6
|
+
- MCP server for AI coding agents (sentinel mcp)
|
|
7
|
+
- Remote fix with PR creation (sentinel fix owner/repo)
|
|
8
|
+
- Policy engine wired into GitHub Action
|
|
9
|
+
|
|
10
|
+
### Security Fixes
|
|
11
|
+
- Git credential leakage prevention in action fix mode
|
|
12
|
+
- Prompt injection mitigation in AI fix (XML fences + UNTRUSTED warning)
|
|
13
|
+
- Annotation injection sanitization
|
|
14
|
+
- Tempfile race condition in policy loading
|
|
15
|
+
|
|
16
|
+
### Test Coverage
|
|
17
|
+
- 459 tests, 1358 assertions
|
|
18
|
+
- Added: ShaResolver, RuleEngine, bot state, formatter, CLI fix tests
|
|
19
|
+
- All 28 rules have test coverage
|
|
20
|
+
|
|
3
21
|
## 0.2.0 (2026-05-16)
|
|
4
22
|
|
|
5
23
|
### New Rules (7)
|
data/README.md
CHANGED
|
@@ -200,6 +200,27 @@ ruby bot/scanner_bot.rb --pattern shell-injection --dry-run
|
|
|
200
200
|
- Opt-out support, clear bot identity
|
|
201
201
|
- Runs as daily cron via GitHub Actions
|
|
202
202
|
|
|
203
|
+
## MCP Server
|
|
204
|
+
|
|
205
|
+
Use Sentinel as a tool in AI coding agents (Claude Code, Copilot, Cursor):
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
# Start the MCP server
|
|
209
|
+
sentinel mcp
|
|
210
|
+
|
|
211
|
+
# Configure in Claude Code (~/.claude.json)
|
|
212
|
+
{
|
|
213
|
+
"mcpServers": {
|
|
214
|
+
"sentinel": {
|
|
215
|
+
"command": "sentinel",
|
|
216
|
+
"args": ["mcp"]
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Three tools available: `sentinel_scan`, `sentinel_deps`, `sentinel_fix`.
|
|
223
|
+
|
|
203
224
|
## Supply Chain Analysis
|
|
204
225
|
|
|
205
226
|
Map third-party action dependencies with risk scoring:
|
|
@@ -259,6 +280,9 @@ lib/
|
|
|
259
280
|
rules/
|
|
260
281
|
base.rb # abstract rule interface
|
|
261
282
|
*.rb # one file per rule (27 rules)
|
|
283
|
+
mcp/
|
|
284
|
+
server.rb # MCP server for AI coding agents
|
|
285
|
+
claude-code-config.json # example configuration for Claude Code
|
|
262
286
|
bot/
|
|
263
287
|
scanner_bot.rb # PR bot orchestrator
|
|
264
288
|
search.rb # GitHub Code Search client
|
data/bin/sentinel
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../lib/version"
|
|
4
4
|
|
|
5
|
-
SUBCOMMANDS = %w[scan fix deps bot hook version help].freeze
|
|
5
|
+
SUBCOMMANDS = %w[scan fix deps bot hook mcp version help].freeze
|
|
6
6
|
|
|
7
7
|
HELP_TEXT = <<~HELP
|
|
8
8
|
Usage: sentinel <command> [options] [args]
|
|
@@ -13,6 +13,7 @@ HELP_TEXT = <<~HELP
|
|
|
13
13
|
deps [REPO] Map third-party action dependencies and risk factors
|
|
14
14
|
bot Run the PR bot
|
|
15
15
|
hook [install|uninstall|run] Pre-commit hook for workflow file scanning
|
|
16
|
+
mcp Start MCP server (for AI coding agents)
|
|
16
17
|
version Print version
|
|
17
18
|
help Show this help message
|
|
18
19
|
|
|
@@ -58,6 +59,9 @@ when "bot"
|
|
|
58
59
|
require_relative "../lib/cli/bot"
|
|
59
60
|
when "hook"
|
|
60
61
|
require_relative "../lib/cli/hook"
|
|
62
|
+
when "mcp"
|
|
63
|
+
require_relative "../mcp/server"
|
|
64
|
+
McpServer.new.run
|
|
61
65
|
when "version"
|
|
62
66
|
puts "sentinel #{Sentinel::VERSION}"
|
|
63
67
|
when "help"
|
data/lib/version.rb
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Add one of these configurations to ~/.claude.json or .claude/settings.json",
|
|
3
|
+
|
|
4
|
+
"mcpServers": {
|
|
5
|
+
"sentinel-gem": {
|
|
6
|
+
"_comment": "If installed via: gem install sentinel-ci",
|
|
7
|
+
"command": "sentinel",
|
|
8
|
+
"args": ["mcp"]
|
|
9
|
+
},
|
|
10
|
+
"sentinel-local": {
|
|
11
|
+
"_comment": "If running from a local clone",
|
|
12
|
+
"command": "ruby",
|
|
13
|
+
"args": ["mcp/server.rb"],
|
|
14
|
+
"cwd": "/path/to/sentinel"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
data/mcp/server.rb
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
$LOAD_PATH.unshift(File.join(__dir__, "..", "lib"))
|
|
5
|
+
require "scanner"
|
|
6
|
+
require "supply_chain"
|
|
7
|
+
require "version"
|
|
8
|
+
|
|
9
|
+
class McpServer
|
|
10
|
+
def initialize
|
|
11
|
+
@running = true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run
|
|
15
|
+
$stderr.puts "Sentinel MCP server v#{Sentinel::VERSION} starting..."
|
|
16
|
+
while @running && (line = $stdin.gets)
|
|
17
|
+
line = line.strip
|
|
18
|
+
next if line.empty?
|
|
19
|
+
|
|
20
|
+
begin
|
|
21
|
+
request = JSON.parse(line)
|
|
22
|
+
response = handle(request)
|
|
23
|
+
write_response(response) if response
|
|
24
|
+
rescue JSON::ParserError => e
|
|
25
|
+
write_response(error_response(nil, -32700, "Parse error: #{e.message}"))
|
|
26
|
+
rescue => e
|
|
27
|
+
$stderr.puts "Error: #{e.message}"
|
|
28
|
+
write_response(error_response(request&.dig("id"), -32603, e.message))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def handle(request)
|
|
36
|
+
id = request["id"]
|
|
37
|
+
method = request["method"]
|
|
38
|
+
|
|
39
|
+
case method
|
|
40
|
+
when "initialize"
|
|
41
|
+
result_response(id, {
|
|
42
|
+
protocolVersion: "2024-11-05",
|
|
43
|
+
capabilities: { tools: {} },
|
|
44
|
+
serverInfo: { name: "sentinel", version: Sentinel::VERSION }
|
|
45
|
+
})
|
|
46
|
+
when "notifications/initialized"
|
|
47
|
+
nil # no response for notifications
|
|
48
|
+
when "tools/list"
|
|
49
|
+
result_response(id, { tools: tool_definitions })
|
|
50
|
+
when "tools/call"
|
|
51
|
+
tool_name = request.dig("params", "name")
|
|
52
|
+
args = request.dig("params", "arguments") || {}
|
|
53
|
+
execute_tool(id, tool_name, args)
|
|
54
|
+
when "ping"
|
|
55
|
+
result_response(id, {})
|
|
56
|
+
else
|
|
57
|
+
error_response(id, -32601, "Method not found: #{method}")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def tool_definitions
|
|
62
|
+
[
|
|
63
|
+
{
|
|
64
|
+
name: "sentinel_scan",
|
|
65
|
+
description: "Scan a GitHub repo or local path for CI/CD security vulnerabilities. Returns findings with severity, rule, file, line, and fix recommendations.",
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: {
|
|
69
|
+
target: { type: "string", description: "GitHub repo (owner/repo) or local path" },
|
|
70
|
+
severity: { type: "string", enum: ["critical", "high", "medium", "low"], description: "Minimum severity threshold (default: low)" },
|
|
71
|
+
format: { type: "string", enum: ["json", "sarif"], description: "Output format (default: json)" }
|
|
72
|
+
},
|
|
73
|
+
required: ["target"]
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "sentinel_deps",
|
|
78
|
+
description: "Analyze third-party action dependencies for a repo with risk scoring. Shows which actions you depend on, their maintainers, stars, and risk factors.",
|
|
79
|
+
inputSchema: {
|
|
80
|
+
type: "object",
|
|
81
|
+
properties: {
|
|
82
|
+
target: { type: "string", description: "GitHub repo (owner/repo) or local path" }
|
|
83
|
+
},
|
|
84
|
+
required: ["target"]
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "sentinel_fix",
|
|
89
|
+
description: "Auto-fix security findings in workflow files. Returns diffs of what would be changed. Supports 6 mechanical fixes (unpinned actions, shell injection, persist-credentials, missing permissions, missing timeouts, workflow dispatch injection).",
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: "object",
|
|
92
|
+
properties: {
|
|
93
|
+
target: { type: "string", description: "Local path to scan and fix" },
|
|
94
|
+
dry_run: { type: "boolean", description: "If true, return diffs without writing files (default: true)" }
|
|
95
|
+
},
|
|
96
|
+
required: ["target"]
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def execute_tool(id, tool_name, args)
|
|
103
|
+
case tool_name
|
|
104
|
+
when "sentinel_scan"
|
|
105
|
+
do_scan(id, args)
|
|
106
|
+
when "sentinel_deps"
|
|
107
|
+
do_deps(id, args)
|
|
108
|
+
when "sentinel_fix"
|
|
109
|
+
do_fix(id, args)
|
|
110
|
+
else
|
|
111
|
+
error_response(id, -32602, "Unknown tool: #{tool_name}")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def do_scan(id, args)
|
|
116
|
+
target = args["target"]
|
|
117
|
+
severity = (args["severity"] || "low").to_sym
|
|
118
|
+
format = args["format"] || "json"
|
|
119
|
+
|
|
120
|
+
client = if File.directory?(target)
|
|
121
|
+
LocalClient.new(target)
|
|
122
|
+
else
|
|
123
|
+
token = ENV["GITHUB_TOKEN"]
|
|
124
|
+
if token
|
|
125
|
+
GitHubClient.new(token: token)
|
|
126
|
+
else
|
|
127
|
+
CloneClient.new
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
formatter = format == "sarif" ? Formatter::Sarif.new : Formatter::Json.new
|
|
132
|
+
scanner = Scanner.new(client: client, formatter: formatter, min_severity: severity)
|
|
133
|
+
|
|
134
|
+
begin
|
|
135
|
+
result = scanner.scan(target)
|
|
136
|
+
text_response(id, result[:output])
|
|
137
|
+
ensure
|
|
138
|
+
client.cleanup if client.respond_to?(:cleanup)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def do_deps(id, args)
|
|
143
|
+
target = args["target"]
|
|
144
|
+
token = ENV["GITHUB_TOKEN"]
|
|
145
|
+
|
|
146
|
+
workflows = if File.directory?(target)
|
|
147
|
+
LocalClient.new(target).fetch_workflows(target).map { |w|
|
|
148
|
+
Workflow.new(filename: w[:filename], content: w[:content])
|
|
149
|
+
}
|
|
150
|
+
else
|
|
151
|
+
client = token ? GitHubClient.new(token: token) : CloneClient.new
|
|
152
|
+
begin
|
|
153
|
+
raw = client.fetch_workflows(target)
|
|
154
|
+
raw.map { |w| Workflow.new(filename: w[:filename], content: w[:content]) }
|
|
155
|
+
ensure
|
|
156
|
+
client.cleanup if client.respond_to?(:cleanup)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
chain = SupplyChain.new(token: token)
|
|
161
|
+
actions = chain.analyze(workflows)
|
|
162
|
+
text_response(id, JSON.pretty_generate(actions))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def do_fix(id, args)
|
|
166
|
+
target = args["target"]
|
|
167
|
+
dry_run = args.fetch("dry_run", true)
|
|
168
|
+
|
|
169
|
+
unless File.directory?(target)
|
|
170
|
+
return error_response(id, -32602, "sentinel_fix requires a local path, not a remote repo")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Scan first
|
|
174
|
+
client = LocalClient.new(target)
|
|
175
|
+
formatter = Formatter::Json.new
|
|
176
|
+
scanner = Scanner.new(client: client, formatter: formatter, min_severity: :low)
|
|
177
|
+
result = scanner.scan(target)
|
|
178
|
+
|
|
179
|
+
findings = JSON.parse(result[:output])["findings"]
|
|
180
|
+
fixable = findings.select { |f|
|
|
181
|
+
AutoFix.can_fix?(Finding.new(
|
|
182
|
+
rule: f["rule"], severity: f["severity"].to_sym,
|
|
183
|
+
file: f["file"], line: f["line"],
|
|
184
|
+
code: f["code"], message: f["message"], fix: f["fix"]
|
|
185
|
+
))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if fixable.empty?
|
|
189
|
+
return text_response(id, "No auto-fixable findings found.")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Apply fixes
|
|
193
|
+
sha_resolver = ShaResolver.new
|
|
194
|
+
diffs = []
|
|
195
|
+
|
|
196
|
+
fixable.group_by { |f| f["file"] }.each do |file, file_findings|
|
|
197
|
+
path = File.join(target, ".github", "workflows", file)
|
|
198
|
+
next unless File.exist?(path)
|
|
199
|
+
|
|
200
|
+
content = File.read(path)
|
|
201
|
+
original = content.dup
|
|
202
|
+
|
|
203
|
+
file_findings.sort_by { |f| -(f["line"] || 0) }.each do |raw|
|
|
204
|
+
finding = Finding.new(
|
|
205
|
+
rule: raw["rule"], severity: raw["severity"].to_sym,
|
|
206
|
+
file: raw["file"], line: raw["line"],
|
|
207
|
+
code: raw["code"], message: raw["message"], fix: raw["fix"]
|
|
208
|
+
)
|
|
209
|
+
patched = AutoFix.apply(finding, content, sha_resolver: sha_resolver)
|
|
210
|
+
content = patched if patched && patched != content
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
if content != original
|
|
214
|
+
diffs << "--- .github/workflows/#{file}\n+++ .github/workflows/#{file} (fixed)\n#{content}"
|
|
215
|
+
File.write(path, content) unless dry_run
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
summary = dry_run ? "Dry run -- #{diffs.length} files would be fixed" : "#{diffs.length} files fixed"
|
|
220
|
+
text_response(id, "#{summary}\n\n#{diffs.join("\n\n")}")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def result_response(id, result)
|
|
224
|
+
{ jsonrpc: "2.0", id: id, result: result }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def text_response(id, text)
|
|
228
|
+
result_response(id, { content: [{ type: "text", text: text }] })
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def error_response(id, code, message)
|
|
232
|
+
{ jsonrpc: "2.0", id: id, error: { code: code, message: message } }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def write_response(response)
|
|
236
|
+
json = JSON.generate(response)
|
|
237
|
+
$stdout.puts(json)
|
|
238
|
+
$stdout.flush
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
McpServer.new.run if __FILE__ == $0
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sentinel-ci
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jordan Ritter
|
|
@@ -76,6 +76,8 @@ files:
|
|
|
76
76
|
- lib/supply_chain.rb
|
|
77
77
|
- lib/version.rb
|
|
78
78
|
- lib/workflow.rb
|
|
79
|
+
- mcp/claude-code-config.json
|
|
80
|
+
- mcp/server.rb
|
|
79
81
|
homepage: https://sentinel.copilotkit.dev
|
|
80
82
|
licenses:
|
|
81
83
|
- MIT
|