shield_ast 1.2.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6c939647ea823bcc6ab0e154d45ae601348a1d6be7dd3ff5d042a2bd789141f
4
- data.tar.gz: c9f1d745bc005ed3d7c904cf23d09e62094560ee490b3771289ec224de34437d
3
+ metadata.gz: e1d0d992a06ba8323c653c415034bf1a8675927313eed774e0df9604d68ac0d4
4
+ data.tar.gz: dde5863c280c005ff476e9155dd079ef61718ada91d858fdce39139d9d921c67
5
5
  SHA512:
6
- metadata.gz: 83cfc9f81a86de10ccbaaee5950d5495afeb380d73b674438345619e954c05f5a0fcf9b402fe285af83bfc7880642103b48579ea0a898dac71a372ae9e847b34
7
- data.tar.gz: dd36eef5cb01fb5ae39025fdae774b9fa33689f96b3feacda4671544c6c778274c4f4676633b360105968b57abcaa8873c28a40a06e304357802496b8439741c
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
+ [![Gem Version](https://badge.fury.io/rb/shield_ast.svg)](https://badge.fury.io/rb/shield_ast)
4
+ [![Build Status](https://github.com/JAugusto42/shield_ast/actions/workflows/main.yml/badge.svg)](https://github.com/JAugusto42/shield_ast/actions)
5
+ [![Downloads](https://img.shields.io/gem/dt/shield_ast.svg)](https://rubygems.org/gems/shield_ast)
6
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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
@@ -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
- # Execute Semgrep with IaC-specific rulesets
10
- cmd = "semgrep --config=r/terraform --config=r/kubernetes --config=r/docker --config=r/yaml --json --quiet #{path}"
11
- output = `#{cmd}`
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
- if $CHILD_STATUS.success? && !output.strip.empty?
20
+ stdout, _stderr, status = Open3.capture3(*cmd)
21
+
22
+ if status.success? && !stdout.strip.empty?
14
23
  begin
15
- report = JSON.parse(output)
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
@@ -5,10 +5,12 @@ require "json"
5
5
  require "open3"
6
6
 
7
7
  module ShieldAst
8
- # Runs SAST analysis using Semgrep.
8
+ # Wraps the logic for running SAST scan using Semgrep.
9
9
  class SAST
10
10
  def self.scan(path)
11
- cmd = ["semgrep", "scan", path, "--json", "--disable-version-check"]
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?
@@ -4,6 +4,7 @@ require "json"
4
4
 
5
5
  module ShieldAst
6
6
  class SCA
7
+ # Wraps the logic for running SCA scan using Osv Scanner.
7
8
  def self.scan(path)
8
9
  puts "Scanning path: #{path}" if ENV["DEBUG"]
9
10
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ShieldAst
4
- VERSION = "1.2.2"
4
+ VERSION = "1.3.0"
5
5
  end
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
@@ -18,10 +20,10 @@ module ShieldAst
18
20
  SCAN_DATA_FILE = File.join(Dir.pwd, "reports", "scan_data.json")
19
21
  REPORT_JSON_FILE = File.join(Dir.pwd, "reports", "scan_report.json")
20
22
  REPORT_PDF_FILE = File.join(Dir.pwd, "reports", "scan_report.pdf")
21
- PDF_TEMPLATE = File.join(__dir__, "reports", "templates", "pdf_report_template.rb")
23
+ PDF_TEMPLATE = File.join(Dir.pwd, "reports", "templates", "pdf_report_template.rb")
22
24
 
23
25
  def self.call(args)
24
- ascii_banner
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, execution_time)
201
- total_issues = 0
205
+ def self.display_reports(reports)
206
+ gemini_enabled = !ENV["GEMINI_API_KEY"].to_s.empty?
202
207
 
203
- reports.each do |type, report_data|
204
- results = report_data[:results] || report_data["results"] || []
205
- total_issues += results.length
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 = results.length - top_results.length
231
+ remaining_count = sorted_results.length - top_results.length
212
232
 
213
- puts "\n#{get_scan_icon(type)} #{type.to_s.upcase} (#{results.length} #{results.length == 1 ? "issue" : "issues"}#{remaining_count.positive? ? ", showing top 5" : ""})"
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
- format_report(top_results, type)
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 " ... and #{remaining_count} more #{remaining_count == 1 ? "issue" : "issues"} (run with --verbose to see all)"
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 Scan finished in: #{format_duration(execution_time)}"
224
-
225
- if total_issues.zero?
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[:severity] || result["severity"] || result[:extra]&.[](:severity) || result["extra"]&.[]("severity"))
322
- message = result[:extra]&.[](:message) || result["extra"]&.[]("message") || "Unknown issue"
323
- title = message.split(".")[0].strip
324
- file_info = "#{result[:path] || result["path"] || "N/A"}:#{result[:start]&.[](:line) || result["start"]&.[]("line") || "N/A"}"
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} | #{(result[:extra]&.[](:message) || result["extra"]&.[]("message") || "No description available")[0..80]}..."
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 = { command: nil, path: nil, sast: false, sca: false, iac: false, help: false, version: false,
332
- output: nil }
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.ascii_banner
378
- puts <<~BANNER
379
- [>>> SHIELD AST <<<]
380
- powered by open source (semgrep + osv-scanner) \n
381
- BANNER
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.2.2
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