shield_ast 1.2.1 → 1.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 +4 -4
- data/README.md +36 -0
- data/lib/shield_ast/ai_analyzer.rb +85 -0
- data/lib/shield_ast/iac.rb +15 -7
- data/lib/shield_ast/sast.rb +4 -2
- data/lib/shield_ast/sca.rb +1 -0
- data/lib/shield_ast/version.rb +1 -1
- data/lib/shield_ast.rb +82 -39
- metadata +16 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e1d0d992a06ba8323c653c415034bf1a8675927313eed774e0df9604d68ac0d4
|
4
|
+
data.tar.gz: dde5863c280c005ff476e9155dd079ef61718ada91d858fdce39139d9d921c67
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 504a5fd50aa71a6785546e6a5a437f5b6d4dcb864f1006344f656d93135ca63dddd30de2105362a5382afd0fe19a87b5830ee5fd3344a302a14509f494319f20
|
7
|
+
data.tar.gz: bd83dbcc8eb058cf581a4466c94edea750c7ada17bcbd9ac150ad905c51d65859d02a3cea1d65adb4c0a9ebd4ca8f77fbd655b1a14cf74bb3cfa2f2f0b0f206c
|
data/README.md
CHANGED
@@ -1,5 +1,10 @@
|
|
1
1
|
# Shield AST - Application Security Testing CLI
|
2
2
|
|
3
|
+
[](https://badge.fury.io/rb/shield_ast)
|
4
|
+
[](https://github.com/JAugusto42/shield_ast/actions)
|
5
|
+
[](https://rubygems.org/gems/shield_ast)
|
6
|
+
[](LICENSE)
|
7
|
+
|
3
8
|
**Shield AST** is a powerful command-line tool for **Application Security Testing**, combining multiple open-source scanners into a single workflow. With `ast`, you can run **SAST** (Static Application Security Testing), **SCA** (Software Composition Analysis), and **IaC** (Infrastructure as Code) analysis quickly and automatically, helping you identify and fix vulnerabilities early in the development lifecycle.
|
4
9
|
|
5
10
|
---
|
@@ -44,7 +49,38 @@ ast [command] [options]
|
|
44
49
|
- **`--version`** – Show the AST version.
|
45
50
|
|
46
51
|
---
|
52
|
+
## ✨ NEW: AI-Powered False Positive Analysis
|
53
|
+
|
54
|
+
Shield AST can use the **Google Gemini API** to automatically analyze findings and flag potential false positives, helping you focus on what matters most.
|
55
|
+
|
56
|
+
### How to Enable It
|
57
|
+
|
58
|
+
To activate this feature, you need a Google AI API key.
|
59
|
+
|
60
|
+
### 1. Get Your API Key
|
61
|
+
First, you'll need a Google Gemini API key to enable AI analysis.
|
62
|
+
|
63
|
+
1. Navigate to **[Google AI Studio](https://aistudio.google.com/app/apikey)**.
|
64
|
+
2. Click **"Create API key"** (you may need to sign in with your Google account).
|
65
|
+
3. Copy the key once it's generated.
|
47
66
|
|
67
|
+
### 2. Configure Your Environment
|
68
|
+
Next, export the API key as an environment variable in your terminal.
|
69
|
+
|
70
|
+
```bash
|
71
|
+
# Replace with your actual API key
|
72
|
+
export GEMINI_API_KEY="YOUR_API_KEY_HERE"
|
73
|
+
````
|
74
|
+
📌 Tip: This command is temporary and only lasts for the current terminal session.
|
75
|
+
To make it permanent, add the line above to your shell's configuration file (e.g., ~/.zshrc or ~/.bash_profile).
|
76
|
+
|
77
|
+
The tool defaults to the free gemini-2.5-flash model.
|
78
|
+
If you have access to a more powerful model,
|
79
|
+
you can specify it by setting the optional GEMINI_MODEL variable:
|
80
|
+
|
81
|
+
```bash
|
82
|
+
export GEMINI_MODEL="gemini-2.5-pro"
|
83
|
+
```
|
48
84
|
## 📌 Examples
|
49
85
|
|
50
86
|
```bash
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "gemini-ai"
|
4
|
+
|
5
|
+
module ShieldAst
|
6
|
+
class AiAnalyzer
|
7
|
+
attr_reader :model_name, :finding
|
8
|
+
|
9
|
+
def initialize(finding)
|
10
|
+
@model_name = ENV.fetch("GEMINI_MODEL", "gemini-2.5-flash")
|
11
|
+
@finding = finding
|
12
|
+
end
|
13
|
+
|
14
|
+
def call
|
15
|
+
check_for_false_positive
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def check_for_false_positive
|
21
|
+
file_path = finding["path"]
|
22
|
+
line = finding.dig("start", "line")
|
23
|
+
message = finding.dig("extra", "message") || finding["check_id"] || "N/A"
|
24
|
+
code_snippet = extract_code_snippet(file_path, line)
|
25
|
+
prompt = prompt(message, code_snippet, file_path, line)
|
26
|
+
|
27
|
+
begin
|
28
|
+
request_body = { contents: { role: "user", parts: { text: prompt } } }
|
29
|
+
response = client.generate_content(request_body)
|
30
|
+
result_text = response.dig("candidates", 0, "content", "parts", 0, "text")&.strip
|
31
|
+
|
32
|
+
return "\e[33m ⚠️ (Possible False Positive)\e[0m" if result_text == "FALSE_POSITIVE"
|
33
|
+
return "\e[36m 🛡️ (Verified by AI)\e[0m" if result_text == "TRUE_POSITIVE"
|
34
|
+
rescue StandardError
|
35
|
+
puts "\e[31m[!] AI analysis failed.\e[0m"
|
36
|
+
end
|
37
|
+
|
38
|
+
""
|
39
|
+
end
|
40
|
+
|
41
|
+
def client
|
42
|
+
@client ||= Gemini.new(
|
43
|
+
credentials: {
|
44
|
+
service: "generative-language-api",
|
45
|
+
api_key: ENV["GEMINI_API_KEY"]
|
46
|
+
},
|
47
|
+
options: { model: model_name }
|
48
|
+
)
|
49
|
+
end
|
50
|
+
|
51
|
+
def prompt(message, code_snippet, file_path, line)
|
52
|
+
<<~PROMPT
|
53
|
+
Analyze the following security finding. Based on the code and the description, is it more likely to be a true positive or a false positive?
|
54
|
+
|
55
|
+
**Finding:** #{message}
|
56
|
+
**File:** #{file_path || "N/A"}:#{line || "N/A"}
|
57
|
+
|
58
|
+
**Code:**
|
59
|
+
```
|
60
|
+
#{code_snippet}
|
61
|
+
```
|
62
|
+
|
63
|
+
Respond with ONLY ONE of the following words: `TRUE_POSITIVE`, `FALSE_POSITIVE`, or `UNCERTAIN`.
|
64
|
+
PROMPT
|
65
|
+
end
|
66
|
+
|
67
|
+
def extract_code_snippet(file_path, line_number, context_lines = 10)
|
68
|
+
return "Code snippet not available (file not found)." unless file_path && File.exist?(file_path)
|
69
|
+
return "Code snippet not available (line number not specified)." unless line_number
|
70
|
+
|
71
|
+
lines = File.readlines(file_path)
|
72
|
+
start_line = [0, line_number - 1 - context_lines].max
|
73
|
+
end_line = [lines.length - 1, line_number - 1 + context_lines].min
|
74
|
+
|
75
|
+
snippet = []
|
76
|
+
(start_line..end_line).each do |i|
|
77
|
+
line_prefix = i + 1 == line_number ? ">> #{i + 1}: " : " #{i + 1}: "
|
78
|
+
snippet << "#{line_prefix}#{lines[i].chomp}"
|
79
|
+
end
|
80
|
+
snippet.join("\n")
|
81
|
+
rescue StandardError => e
|
82
|
+
"Could not read code snippet: #{e.message}"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
data/lib/shield_ast/iac.rb
CHANGED
@@ -1,25 +1,33 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "English"
|
4
3
|
require "json"
|
4
|
+
require "open3"
|
5
5
|
|
6
6
|
module ShieldAst
|
7
|
+
# Wraps the logic for running Infrastructure as Code (IaC) scans using Semgrep.
|
7
8
|
class IaC
|
8
9
|
def self.scan(path)
|
9
|
-
|
10
|
-
|
11
|
-
|
10
|
+
cmd = [
|
11
|
+
"semgrep", "scan",
|
12
|
+
"--config", "r/terraform",
|
13
|
+
"--config", "r/kubernetes",
|
14
|
+
"--config", "r/docker",
|
15
|
+
"--config", "r/yaml",
|
16
|
+
"--json", "--quiet",
|
17
|
+
path
|
18
|
+
]
|
12
19
|
|
13
|
-
|
20
|
+
stdout, _stderr, status = Open3.capture3(*cmd)
|
21
|
+
|
22
|
+
if status.success? && !stdout.strip.empty?
|
14
23
|
begin
|
15
|
-
report = JSON.parse(
|
24
|
+
report = JSON.parse(stdout)
|
16
25
|
return { "results" => report["results"] || [] }
|
17
26
|
rescue JSON::ParserError
|
18
27
|
return { "results" => [] }
|
19
28
|
end
|
20
29
|
end
|
21
30
|
|
22
|
-
# Fallback if semgrep fails
|
23
31
|
{ "results" => [] }
|
24
32
|
end
|
25
33
|
end
|
data/lib/shield_ast/sast.rb
CHANGED
@@ -5,10 +5,12 @@ require "json"
|
|
5
5
|
require "open3"
|
6
6
|
|
7
7
|
module ShieldAst
|
8
|
-
#
|
8
|
+
# Wraps the logic for running SAST scan using Semgrep.
|
9
9
|
class SAST
|
10
10
|
def self.scan(path)
|
11
|
-
cmd = [
|
11
|
+
cmd = [
|
12
|
+
"semgrep", "scan", "--config", "p/r2c-ci", "--config", "p/secrets", "--json", "--disable-version-check", path
|
13
|
+
]
|
12
14
|
stdout, stderr, status = Open3.capture3(*cmd)
|
13
15
|
|
14
16
|
if status.success?
|
data/lib/shield_ast/sca.rb
CHANGED
data/lib/shield_ast/version.rb
CHANGED
data/lib/shield_ast.rb
CHANGED
@@ -1,13 +1,15 @@
|
|
1
|
-
# lib/shield_ast/main.rb
|
2
1
|
# frozen_string_literal: true
|
3
2
|
|
4
3
|
require_relative "shield_ast/version"
|
5
4
|
require_relative "shield_ast/runner"
|
5
|
+
require_relative "shield_ast/ai_analyzer"
|
6
|
+
|
6
7
|
require "json"
|
7
8
|
require "fileutils"
|
8
9
|
require "erb"
|
9
10
|
require "prawn"
|
10
11
|
require "prawn/table"
|
12
|
+
require "gemini-ai"
|
11
13
|
|
12
14
|
# Main module for the Shield AST gem.
|
13
15
|
module ShieldAst
|
@@ -15,13 +17,13 @@ module ShieldAst
|
|
15
17
|
|
16
18
|
# Main class for the Shield AST command-line tool.
|
17
19
|
class Main
|
18
|
-
SCAN_DATA_FILE = File.join(Dir.pwd, "
|
19
|
-
REPORT_JSON_FILE = File.join(Dir.pwd, "
|
20
|
-
REPORT_PDF_FILE = File.join(Dir.pwd, "
|
21
|
-
PDF_TEMPLATE = File.join(
|
20
|
+
SCAN_DATA_FILE = File.join(Dir.pwd, "reports", "scan_data.json")
|
21
|
+
REPORT_JSON_FILE = File.join(Dir.pwd, "reports", "scan_report.json")
|
22
|
+
REPORT_PDF_FILE = File.join(Dir.pwd, "reports", "scan_report.pdf")
|
23
|
+
PDF_TEMPLATE = File.join(Dir.pwd, "reports", "templates", "pdf_report_template.rb")
|
22
24
|
|
23
25
|
def self.call(args)
|
24
|
-
|
26
|
+
banner
|
25
27
|
|
26
28
|
unless scanner_exists?("osv-scanner") && scanner_exists?("semgrep")
|
27
29
|
puts "\e[31m[!] ERROR:\e[0m Required tools not found."
|
@@ -61,10 +63,10 @@ module ShieldAst
|
|
61
63
|
|
62
64
|
reports = Runner.run(options, path) || {}
|
63
65
|
|
66
|
+
display_reports(reports)
|
67
|
+
|
64
68
|
end_time = Time.now
|
65
69
|
execution_time = end_time - start_time
|
66
|
-
|
67
|
-
display_reports(reports, execution_time)
|
68
70
|
save_scan_data(reports, execution_time)
|
69
71
|
end
|
70
72
|
|
@@ -81,6 +83,9 @@ module ShieldAst
|
|
81
83
|
FileUtils.mkdir_p(File.dirname(SCAN_DATA_FILE))
|
82
84
|
File.write(SCAN_DATA_FILE, JSON.pretty_generate(data))
|
83
85
|
puts "Scan data saved to: #{SCAN_DATA_FILE}"
|
86
|
+
|
87
|
+
puts "\n🕒 Duration:: #{format_duration(execution_time)}"
|
88
|
+
puts "✅ DONE."
|
84
89
|
end
|
85
90
|
|
86
91
|
def self.load_scan_data
|
@@ -197,37 +202,55 @@ module ShieldAst
|
|
197
202
|
end
|
198
203
|
end
|
199
204
|
|
200
|
-
def self.display_reports(reports
|
201
|
-
|
205
|
+
def self.display_reports(reports)
|
206
|
+
gemini_enabled = !ENV["GEMINI_API_KEY"].to_s.empty?
|
202
207
|
|
203
|
-
reports.
|
204
|
-
|
205
|
-
|
208
|
+
total_issues = flatten_findings(reports).length
|
209
|
+
|
210
|
+
if total_issues.zero?
|
211
|
+
puts "✅ No security issues found! Your code looks clean."
|
212
|
+
return
|
213
|
+
end
|
214
|
+
|
215
|
+
puts "\nScan Results:"
|
216
|
+
if gemini_enabled
|
217
|
+
model = ENV.fetch("GEMINI_MODEL", "gemini-2.5-flash")
|
218
|
+
puts "\e[34m🔑 Gemini API key found. False positive analysis enabled (this may slow down the scan).\e[0m"
|
219
|
+
puts "🤖 AI Model: #{model}"
|
220
|
+
end
|
206
221
|
|
222
|
+
reports.each do |scan_type, report_data|
|
223
|
+
next unless report_data.is_a?(Hash)
|
224
|
+
|
225
|
+
results = report_data[:results] || report_data["results"] || []
|
207
226
|
next if results.empty?
|
208
227
|
|
209
228
|
sorted_results = sort_by_severity(results)
|
229
|
+
|
210
230
|
top_results = sorted_results.first(5)
|
211
|
-
remaining_count =
|
231
|
+
remaining_count = sorted_results.length - top_results.length
|
212
232
|
|
213
|
-
puts "\n#{get_scan_icon(
|
233
|
+
puts "\n#{get_scan_icon(scan_type.to_sym)} #{scan_type.to_s.upcase} (#{results.length} #{results.length == 1 ? "issue" : "issues"}#{remaining_count.positive? ? ", showing top 5" : ""})"
|
214
234
|
puts "-" * 60
|
215
235
|
|
216
|
-
|
236
|
+
top_results.each do |result|
|
237
|
+
if scan_type.to_sym == :sca && has_sca_format?(result)
|
238
|
+
format_sca_result(result)
|
239
|
+
else
|
240
|
+
fp_indicator = gemini_enabled ? ShieldAst::AiAnalyzer.new(result).call : ""
|
241
|
+
format_default_result(result, fp_indicator)
|
242
|
+
end
|
243
|
+
puts ""
|
244
|
+
end
|
217
245
|
|
218
246
|
if remaining_count.positive?
|
219
|
-
puts "
|
247
|
+
puts "... and #{remaining_count} more #{remaining_count == 1 ? "issue" : "issues"}. See the full report for details."
|
220
248
|
end
|
221
249
|
end
|
222
250
|
|
223
|
-
puts "\n
|
224
|
-
|
225
|
-
|
226
|
-
puts "✅ No security issues found! Your code looks clean."
|
227
|
-
else
|
228
|
-
severity_summary = calculate_severity_summary(reports)
|
229
|
-
puts "📊 Total: #{total_issues} findings {error_count: #{severity_summary[:error_count]}, warning_count: #{severity_summary[:warning_count]}, info_count: #{severity_summary[:info_count]}}"
|
230
|
-
end
|
251
|
+
puts "\n#{"=" * 60}"
|
252
|
+
severity_summary = calculate_severity_summary(reports)
|
253
|
+
puts "📊 Total: #{total_issues} findings {error_count: #{severity_summary[:error_count]}, warning_count: #{severity_summary[:warning_count]}, info_count: #{severity_summary[:info_count]}}"
|
231
254
|
end
|
232
255
|
|
233
256
|
def self.sort_by_severity(results)
|
@@ -317,19 +340,22 @@ module ShieldAst
|
|
317
340
|
puts " 📁 #{result[:file] || result["file"]} | #{(result[:description] || result["description"] || "")[0..80]}#{(result[:description] || result["description"] || "").length > 80 ? "..." : ""}"
|
318
341
|
end
|
319
342
|
|
320
|
-
def self.format_default_result(result)
|
321
|
-
severity_icon = get_severity_icon(result
|
322
|
-
message = result
|
323
|
-
title = message.split(".")
|
324
|
-
file_info = "#{
|
343
|
+
def self.format_default_result(result, fp_indicator = "")
|
344
|
+
severity_icon = get_severity_icon(result.dig("extra", "severity") || result["severity"])
|
345
|
+
message = result.dig("extra", "message") || result["check_id"] || "Unknown issue"
|
346
|
+
title = message.split(".").first&.strip || message
|
347
|
+
file_info = "#{result["path"] || "N/A"}:#{result.dig("start", "line") || "N/A"}"
|
325
348
|
|
326
|
-
puts " #{severity_icon} #{title}"
|
327
|
-
puts " 📁 #{file_info}
|
349
|
+
puts " #{severity_icon} #{title}#{fp_indicator}"
|
350
|
+
puts " 📁 #{file_info}"
|
351
|
+
puts " #{message}"
|
328
352
|
end
|
329
353
|
|
330
354
|
def self.parse_args(args)
|
331
|
-
options = {
|
332
|
-
|
355
|
+
options = {
|
356
|
+
command: nil, path: nil, sast: false, sca: false, iac: false, help: false, version: false, output: nil
|
357
|
+
}
|
358
|
+
|
333
359
|
args.each_with_index do |arg, index|
|
334
360
|
case arg
|
335
361
|
when "scan" then options[:command] = "scan"
|
@@ -347,6 +373,18 @@ module ShieldAst
|
|
347
373
|
options
|
348
374
|
end
|
349
375
|
|
376
|
+
def self.flatten_findings(reports)
|
377
|
+
findings = []
|
378
|
+
reports.each do |scan_type, report_data|
|
379
|
+
results = report_data[:results] || report_data["results"] || []
|
380
|
+
|
381
|
+
results.each do |result|
|
382
|
+
findings << result.merge(scan_type: scan_type.to_sym)
|
383
|
+
end
|
384
|
+
end
|
385
|
+
sort_by_severity(findings)
|
386
|
+
end
|
387
|
+
|
350
388
|
def self.show_help
|
351
389
|
puts <<~HELP
|
352
390
|
ast - A powerful command-line tool for Application Security Testing
|
@@ -374,11 +412,16 @@ module ShieldAst
|
|
374
412
|
HELP
|
375
413
|
end
|
376
414
|
|
377
|
-
def self.
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
415
|
+
def self.banner
|
416
|
+
yellow = "\e[33m"
|
417
|
+
reset = "\e[0m"
|
418
|
+
version_string = "Shield AST - v#{ShieldAst::VERSION}"
|
419
|
+
line_length = 42
|
420
|
+
|
421
|
+
puts "#{yellow}┌" + "─" * line_length + "┐#{reset}"
|
422
|
+
puts "#{yellow}│#{reset} #{version_string.ljust(line_length - 1)}#{yellow}│#{reset}"
|
423
|
+
puts "#{yellow}└" + "─" * line_length + "┘#{reset}"
|
424
|
+
puts ""
|
382
425
|
end
|
383
426
|
end
|
384
427
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: shield_ast
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jose Augusto
|
@@ -37,6 +37,20 @@ dependencies:
|
|
37
37
|
- - "~>"
|
38
38
|
- !ruby/object:Gem::Version
|
39
39
|
version: '1.7'
|
40
|
+
- !ruby/object:Gem::Dependency
|
41
|
+
name: gemini-ai
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - "~>"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '4.3'
|
47
|
+
type: :runtime
|
48
|
+
prerelease: false
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - "~>"
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '4.3'
|
40
54
|
- !ruby/object:Gem::Dependency
|
41
55
|
name: json
|
42
56
|
requirement: !ruby/object:Gem::Requirement
|
@@ -98,6 +112,7 @@ files:
|
|
98
112
|
- exe/ast
|
99
113
|
- lib/reports/templates/pdf_report_template.rb
|
100
114
|
- lib/shield_ast.rb
|
115
|
+
- lib/shield_ast/ai_analyzer.rb
|
101
116
|
- lib/shield_ast/iac.rb
|
102
117
|
- lib/shield_ast/runner.rb
|
103
118
|
- lib/shield_ast/sast.rb
|