pwn 0.5.613 → 0.5.615
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/Gemfile +10 -10
- data/README.md +110 -24
- data/documentation/AI-Integration.md +36 -0
- data/documentation/BurpSuite.md +26 -0
- data/documentation/Contributing.md +31 -0
- data/documentation/Diagrams.md +57 -0
- data/documentation/Drivers.md +25 -0
- data/documentation/General-PWN-Usage.md +40 -0
- data/documentation/Home.md +51 -0
- data/documentation/How-PWN-Works.md +60 -0
- data/documentation/Installation.md +50 -0
- data/documentation/NmapIt.md +18 -0
- data/documentation/Plugins.md +59 -0
- data/documentation/Reporting.md +28 -0
- data/documentation/SAST.md +32 -0
- data/documentation/Skills-Memory-Learning.md +44 -0
- data/documentation/Transparent-Browser.md +32 -0
- data/documentation/Troubleshooting.md +38 -0
- data/documentation/What-is-PWN.md +34 -0
- data/documentation/Why-PWN.md +18 -0
- data/documentation/diagrams/ai-integration-tool-calling.svg +134 -0
- data/documentation/diagrams/burp-vs-zap-preference.svg +84 -0
- data/documentation/diagrams/code-scanning-sast.svg +112 -0
- data/documentation/diagrams/dot/ai-integration-tool-calling.dot +23 -0
- data/documentation/diagrams/dot/burp-vs-zap-preference.dot +15 -0
- data/documentation/diagrams/dot/code-scanning-sast.dot +21 -0
- data/documentation/diagrams/dot/driver-framework.dot +17 -0
- data/documentation/diagrams/dot/fuzzing-workflow.dot +27 -0
- data/documentation/diagrams/dot/history-to-drivers.dot +20 -0
- data/documentation/diagrams/dot/memory-skills-detailed.dot +28 -0
- data/documentation/diagrams/dot/network-infra-testing.dot +22 -0
- data/documentation/diagrams/dot/overall-pwn-architecture.dot +49 -0
- data/documentation/diagrams/dot/penetration-testing-workflow.dot +29 -0
- data/documentation/diagrams/dot/plugin-ecosystem.dot +31 -0
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +57 -0
- data/documentation/diagrams/dot/pwn-repl-prototyping.dot +37 -0
- data/documentation/diagrams/dot/reporting-pipeline.dot +17 -0
- data/documentation/diagrams/dot/reverse-engineering-flow.dot +21 -0
- data/documentation/diagrams/dot/sessions-cron-automation.dot +17 -0
- data/documentation/diagrams/dot/web-application-testing.dot +27 -0
- data/documentation/diagrams/dot/zero-day-research-flow.dot +27 -0
- data/documentation/diagrams/driver-framework.svg +90 -0
- data/documentation/diagrams/fuzzing-workflow.svg +147 -0
- data/documentation/diagrams/history-to-drivers.svg +117 -0
- data/documentation/diagrams/memory-skills-detailed.svg +154 -0
- data/documentation/diagrams/network-infra-testing.svg +121 -0
- data/documentation/diagrams/overall-pwn-architecture.svg +234 -0
- data/documentation/diagrams/penetration-testing-workflow.svg +148 -0
- data/documentation/diagrams/plugin-ecosystem.svg +172 -0
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +234 -0
- data/documentation/diagrams/pwn-repl-prototyping.svg +179 -0
- data/documentation/diagrams/reporting-pipeline.svg +91 -0
- data/documentation/diagrams/reverse-engineering-flow.svg +114 -0
- data/documentation/diagrams/sessions-cron-automation.svg +92 -0
- data/documentation/diagrams/web-application-testing.svg +137 -0
- data/documentation/diagrams/zero-day-research-flow.svg +149 -0
- data/documentation/pwn-REPL.md +33 -0
- data/documentation/pwn-ai-Agent.md +43 -0
- data/lib/pwn/ai/agent/learning.rb +377 -0
- data/lib/pwn/ai/agent/loop.rb +18 -3
- data/lib/pwn/ai/agent/metrics.rb +155 -0
- data/lib/pwn/ai/agent/prompt_builder.rb +23 -3
- data/lib/pwn/ai/agent/tools/learning.rb +113 -0
- data/lib/pwn/ai/agent/tools/skills.rb +94 -8
- data/lib/pwn/ai/agent.rb +2 -0
- data/lib/pwn/ai/grok.rb +249 -106
- data/lib/pwn/config.rb +86 -13
- data/lib/pwn/plugins/monkey_patch.rb +2 -2
- data/lib/pwn/plugins/repl.rb +68 -3
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/learning_spec.rb +68 -0
- data/spec/lib/pwn/ai/agent/metrics_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/tools/learning_spec.rb +30 -0
- metadata +84 -34
- data/documentation/PWN.png +0 -0
- data/documentation/PWN_Contributors_and_Users.png +0 -0
- data/documentation/fax-spectrogram.png +0 -0
- data/documentation/fax-waveform.png +0 -0
- data/documentation/frozen_string_literal_files_changed_from_false_to_true.txt +0 -54
- data/documentation/lifecycle_authz_replay.example.yaml +0 -27
- data/documentation/pwn_android_war_dialer_session.png +0 -0
- data/documentation/pwn_install.png +0 -0
- data/documentation/pwn_wallpaper.jpg +0 -0
- data/documentation/ringing-spectrogram.png +0 -0
- data/documentation/ringing-waveform.png +0 -0
- data/documentation/vulnerability_report_template.md +0 -37
data/lib/pwn/ai/grok.rb
CHANGED
|
@@ -13,115 +13,227 @@ module PWN
|
|
|
13
13
|
# API documentation: https://docs.x.ai/docs
|
|
14
14
|
# Obtain an API key from https://x.ai/api
|
|
15
15
|
module Grok
|
|
16
|
+
# Internal helper: true when +opts[:value]+ is a *real* configured value coming
|
|
17
|
+
# from PWN::Config / pwn-vault (i.e. not nil, not blank, and not one of
|
|
18
|
+
# the "optional - ..." / "required - ..." placeholder strings that
|
|
19
|
+
# PWN::Config.default_env writes into a fresh ~/.pwn/pwn.yaml).
|
|
20
|
+
# Used so OAuth + API key resolution behaves correctly regardless of
|
|
21
|
+
# whether the user edited every field.
|
|
22
|
+
private_class_method def self.real_config_value?(opts = {})
|
|
23
|
+
s = opts[:value].to_s.strip
|
|
24
|
+
return false if s.empty?
|
|
25
|
+
return false if s.match?(/\A(optional|required)\b/i)
|
|
26
|
+
|
|
27
|
+
true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
# xAI Grok OAuth (SuperGrok) -- public-client OIDC, no client_secret.
|
|
32
|
+
#
|
|
33
|
+
# auth.x.ai is a full OIDC provider (/.well-known/openid-configuration).
|
|
34
|
+
# xAI ships a PUBLIC client for the Grok CLI; the same client_id is used
|
|
35
|
+
# by NousResearch/hermes-agent (`hermes auth add xai-oauth`) and is reused
|
|
36
|
+
# here so PWN can obtain a Bearer for https://api.x.ai/v1 without an API
|
|
37
|
+
# key. token_endpoint_auth_methods_supported includes 'none', so no
|
|
38
|
+
# client_secret is required -- only PKCE / device_code.
|
|
39
|
+
#
|
|
40
|
+
# Two grants are implemented:
|
|
41
|
+
# * RFC 8628 device_authorization -> default; headless / SSH friendly.
|
|
42
|
+
# * refresh_token -> silent renewal once enrolled.
|
|
43
|
+
#
|
|
44
|
+
# Access tokens are short-lived JWTs (ES256, `exp` claim). The
|
|
45
|
+
# refresh_token is long-lived -- persist it via pwn-vault under
|
|
46
|
+
# ai.grok.oauth.refresh_token and PWN::AI::Grok refreshes transparently
|
|
47
|
+
# on every run.
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
XAI_OAUTH_ISSUER = 'https://auth.x.ai'
|
|
50
|
+
XAI_OAUTH_DEVICE_URI = "#{XAI_OAUTH_ISSUER}/oauth2/device/code".freeze
|
|
51
|
+
XAI_OAUTH_TOKEN_URI = "#{XAI_OAUTH_ISSUER}/oauth2/token".freeze
|
|
52
|
+
# Public Grok-CLI client_id (same one hermes-agent uses).
|
|
53
|
+
XAI_OAUTH_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828'
|
|
54
|
+
XAI_OAUTH_SCOPE = 'openid profile email offline_access grok-cli:access api:access'
|
|
55
|
+
|
|
56
|
+
# Internal: decode a JWT payload (no sig verification) to read `exp`.
|
|
57
|
+
private_class_method def self.jwt_exp(opts = {})
|
|
58
|
+
seg = opts[:token].to_s.split('.')[1]
|
|
59
|
+
return nil unless seg
|
|
60
|
+
|
|
61
|
+
seg += '=' * ((4 - (seg.length % 4)) % 4)
|
|
62
|
+
JSON.parse(Base64.urlsafe_decode64(seg))['exp']
|
|
63
|
+
rescue StandardError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Internal: true when +opts[:token]+ is absent, not a JWT, or expires within
|
|
68
|
+
# +opts[:skew]+ seconds (default 120).
|
|
69
|
+
private_class_method def self.oauth_token_expiring?(opts = {})
|
|
70
|
+
token = opts[:token]
|
|
71
|
+
skew = opts[:skew] || 120
|
|
72
|
+
return true unless real_config_value?(value: token)
|
|
73
|
+
|
|
74
|
+
exp = jwt_exp(token: token)
|
|
75
|
+
return false if exp.nil? # opaque token -- trust it
|
|
76
|
+
|
|
77
|
+
Time.now.to_i >= (exp.to_i - skew)
|
|
78
|
+
end
|
|
79
|
+
|
|
16
80
|
# Supported Method Parameters::
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# token_uri:
|
|
81
|
+
# access_token = PWN::AI::Grok.refresh_oauth_bearer_token(
|
|
82
|
+
# refresh_token: 'required - xAI OAuth refresh_token',
|
|
83
|
+
# client_id: 'optional - defaults to public Grok-CLI client',
|
|
84
|
+
# token_uri: 'optional - defaults to https://auth.x.ai/oauth2/token'
|
|
21
85
|
# )
|
|
22
86
|
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
|
|
87
|
+
# Exchanges a refresh_token for a fresh access_token at auth.x.ai.
|
|
88
|
+
# On success, writes :bearer_token (and a rotated :refresh_token if
|
|
89
|
+
# returned) back into the passed opts/oauth Hash so the live PWN::Env
|
|
90
|
+
# stays warm for the rest of the process.
|
|
91
|
+
public_class_method def self.refresh_oauth_bearer_token(opts = {})
|
|
92
|
+
refresh_token = opts[:refresh_token]
|
|
93
|
+
raise 'refresh_token is required' unless real_config_value?(value: refresh_token)
|
|
94
|
+
|
|
95
|
+
client_id = real_config_value?(value: opts[:client_id]) ? opts[:client_id] : XAI_OAUTH_CLIENT_ID
|
|
96
|
+
token_uri = real_config_value?(value: opts[:token_uri]) ? opts[:token_uri] : XAI_OAUTH_TOKEN_URI
|
|
97
|
+
|
|
98
|
+
resp = RestClient.post(
|
|
99
|
+
token_uri,
|
|
100
|
+
{
|
|
101
|
+
grant_type: 'refresh_token',
|
|
102
|
+
refresh_token: refresh_token,
|
|
103
|
+
client_id: client_id
|
|
104
|
+
},
|
|
105
|
+
content_type: 'application/x-www-form-urlencoded',
|
|
106
|
+
accept: 'application/json'
|
|
107
|
+
)
|
|
108
|
+
data = JSON.parse(resp.body)
|
|
109
|
+
raise "xAI OAuth refresh error: #{data['error']} - #{data['error_description']}" if data['error']
|
|
110
|
+
|
|
111
|
+
opts[:bearer_token] = data['access_token']
|
|
112
|
+
opts[:refresh_token] = data['refresh_token'] if data['refresh_token']
|
|
113
|
+
opts[:expires_at] = Time.now.to_i + data['expires_in'].to_i if data['expires_in']
|
|
114
|
+
data['access_token']
|
|
115
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
116
|
+
raise "xAI OAuth refresh failed (HTTP #{e.http_code}): #{e.response&.body}"
|
|
117
|
+
end
|
|
118
|
+
|
|
29
119
|
# Supported Method Parameters::
|
|
30
120
|
# bearer = PWN::AI::Grok.obtain_oauth_bearer_token(
|
|
31
|
-
# client_id: 'xAI OAuth Client ID',
|
|
32
|
-
#
|
|
33
|
-
#
|
|
121
|
+
# client_id: 'optional - xAI OAuth Client ID (defaults to public Grok-CLI client)',
|
|
122
|
+
# scope: 'optional - space-delimited scopes (defaults to XAI_OAUTH_SCOPE)',
|
|
123
|
+
# timeout: 'optional - seconds to wait for user consent (default 300)'
|
|
34
124
|
# )
|
|
35
125
|
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
126
|
+
# Runs the RFC 8628 OAuth 2.0 Device Authorization Grant against
|
|
127
|
+
# auth.x.ai using xAI's public Grok-CLI client (no client_secret).
|
|
128
|
+
# This is the same identity path `hermes auth add xai-oauth` uses --
|
|
129
|
+
# a SuperGrok / X Premium+ subscription on the account is what grants
|
|
130
|
+
# api:access at consent time.
|
|
39
131
|
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
132
|
+
# 1. POST /oauth2/device/code -> device_code, user_code, verification_uri
|
|
133
|
+
# 2. User opens verification_uri_complete in a browser and approves.
|
|
134
|
+
# 3. Poll POST /oauth2/token (grant_type=device_code) until access_token.
|
|
135
|
+
#
|
|
136
|
+
# On success the access_token + refresh_token are written back into the
|
|
137
|
+
# passed opts/oauth Hash (so PWN::Env[:ai][:grok][:oauth] is live-cached)
|
|
138
|
+
# and the operator is told exactly what to persist via pwn-vault.
|
|
45
139
|
public_class_method def self.obtain_oauth_bearer_token(opts = {})
|
|
46
|
-
client_id = opts[:client_id]
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
authorize_url = "#{auth_uri}?#{URI.encode_www_form(params)}"
|
|
140
|
+
client_id = real_config_value?(value: opts[:client_id]) ? opts[:client_id] : XAI_OAUTH_CLIENT_ID
|
|
141
|
+
scope = real_config_value?(value: opts[:scope]) ? opts[:scope] : XAI_OAUTH_SCOPE
|
|
142
|
+
token_uri = real_config_value?(value: opts[:token_uri]) ? opts[:token_uri] : XAI_OAUTH_TOKEN_URI
|
|
143
|
+
timeout = (opts[:timeout] || 300).to_i
|
|
144
|
+
|
|
145
|
+
# -- Step 1: request device + user code -------------------------------
|
|
146
|
+
dev = JSON.parse(
|
|
147
|
+
RestClient.post(
|
|
148
|
+
XAI_OAUTH_DEVICE_URI,
|
|
149
|
+
{ client_id: client_id, scope: scope },
|
|
150
|
+
content_type: 'application/x-www-form-urlencoded',
|
|
151
|
+
accept: 'application/json'
|
|
152
|
+
).body
|
|
153
|
+
)
|
|
154
|
+
raise "xAI device_code error: #{dev['error']} - #{dev['error_description']}" if dev['error']
|
|
62
155
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
156
|
+
device_code = dev['device_code']
|
|
157
|
+
user_code = dev['user_code']
|
|
158
|
+
verify_uri = dev['verification_uri_complete'] || dev['verification_uri']
|
|
159
|
+
interval = (dev['interval'] || 5).to_i
|
|
160
|
+
expires_in = (dev['expires_in'] || timeout).to_i
|
|
161
|
+
deadline = Time.now.to_i + [expires_in, timeout].min
|
|
162
|
+
|
|
163
|
+
puts "\n[*] xAI Grok OAuth -- Device Authorization (RFC 8628, public client, no secret)"
|
|
164
|
+
puts ' A SuperGrok / X Premium+ subscription on the approving account is required.'
|
|
69
165
|
puts ''
|
|
70
|
-
puts ' Step
|
|
166
|
+
puts ' Step 1: In a browser (any device), open:'
|
|
167
|
+
puts " #{verify_uri}"
|
|
168
|
+
puts " Step 2: Confirm the code matches: #{user_code}"
|
|
169
|
+
puts ' Step 3: Approve access for "Grok CLI".'
|
|
71
170
|
puts ''
|
|
171
|
+
puts " Waiting for approval (polling every #{interval}s, timeout #{deadline - Time.now.to_i}s)..."
|
|
172
|
+
|
|
173
|
+
# -- Step 2: poll the token endpoint ---------------------------------
|
|
174
|
+
data = nil
|
|
175
|
+
loop do
|
|
176
|
+
sleep interval
|
|
177
|
+
begin
|
|
178
|
+
tok = RestClient.post(
|
|
179
|
+
token_uri,
|
|
180
|
+
{
|
|
181
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
182
|
+
device_code: device_code,
|
|
183
|
+
client_id: client_id
|
|
184
|
+
},
|
|
185
|
+
content_type: 'application/x-www-form-urlencoded',
|
|
186
|
+
accept: 'application/json'
|
|
187
|
+
)
|
|
188
|
+
data = JSON.parse(tok.body)
|
|
189
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
190
|
+
data = begin
|
|
191
|
+
JSON.parse(e.response.body)
|
|
192
|
+
rescue StandardError
|
|
193
|
+
{ 'error' => "http_#{e.http_code}", 'error_description' => e.response&.body }
|
|
194
|
+
end
|
|
195
|
+
end
|
|
72
196
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
authorization: basic
|
|
92
|
-
}
|
|
93
|
-
)
|
|
197
|
+
case data['error']
|
|
198
|
+
when nil
|
|
199
|
+
break # success
|
|
200
|
+
when 'authorization_pending'
|
|
201
|
+
raise 'xAI OAuth device flow timed out waiting for user approval.' if Time.now.to_i >= deadline
|
|
202
|
+
|
|
203
|
+
next
|
|
204
|
+
when 'slow_down'
|
|
205
|
+
interval += 5
|
|
206
|
+
next
|
|
207
|
+
when 'access_denied'
|
|
208
|
+
raise 'xAI OAuth device flow: user denied the authorization request.'
|
|
209
|
+
when 'expired_token'
|
|
210
|
+
raise 'xAI OAuth device flow: device_code expired before approval; re-run enrollment.'
|
|
211
|
+
else
|
|
212
|
+
raise "xAI OAuth device flow error: #{data['error']} - #{data['error_description']}"
|
|
213
|
+
end
|
|
214
|
+
end
|
|
94
215
|
|
|
95
|
-
|
|
216
|
+
access_token = data['access_token']
|
|
217
|
+
refresh_token = data['refresh_token']
|
|
218
|
+
raise 'xAI OAuth token endpoint returned no access_token.' unless access_token
|
|
96
219
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
end
|
|
220
|
+
opts[:bearer_token] = access_token
|
|
221
|
+
opts[:refresh_token] = refresh_token if refresh_token
|
|
222
|
+
opts[:expires_at] = Time.now.to_i + data['expires_in'].to_i if data['expires_in']
|
|
101
223
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
puts ' 1. Copy the bearer_token below (and refresh_token if present).'
|
|
112
|
-
puts ' 2. Run your pwn-vault tool (or equivalent) and store under the ai.grok.oauth section:'
|
|
113
|
-
puts " ai.grok.oauth.bearer_token = #{access_token}"
|
|
114
|
-
puts " ai.grok.oauth.refresh_token = #{data['refresh_token']}" if data['refresh_token']
|
|
115
|
-
puts ' 3. (Optional) You may leave or remove client_id/client_secret after storing the bearer.'
|
|
116
|
-
puts ' 4. Next time PWN::Env loads (pwn -Y, pwn REPL, scripts, etc.) the bearer will be present'
|
|
117
|
-
puts ' from your encrypted ~/.pwn/pwn.yaml -- the guard will skip this entire flow.'
|
|
118
|
-
puts ' No more browser prompts or code pasting on future uses.'
|
|
119
|
-
puts ''
|
|
120
|
-
puts ' The token is long-lived for your SuperGrok subscription (xAI manages expiry/refresh as needed).'
|
|
121
|
-
return access_token
|
|
122
|
-
end
|
|
224
|
+
puts "\n[*] SUCCESS: xAI Grok OAuth bearer obtained via device_code grant."
|
|
225
|
+
puts ' Cached in-memory for this pwn / pwn-ai process.'
|
|
226
|
+
puts ''
|
|
227
|
+
puts ' TO MAKE THIS PERMANENT (recommended -- one-time), store via pwn-vault:'
|
|
228
|
+
puts " ai.grok.oauth.refresh_token = #{refresh_token}" if refresh_token
|
|
229
|
+
puts " ai.grok.oauth.bearer_token = #{access_token}"
|
|
230
|
+
puts ' On future runs the refresh_token alone is enough -- PWN::AI::Grok will'
|
|
231
|
+
puts ' silently exchange it for a fresh access_token (no browser, no prompt).'
|
|
232
|
+
puts ''
|
|
123
233
|
|
|
124
|
-
|
|
234
|
+
access_token
|
|
235
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
236
|
+
raise "Failed to obtain Grok OAuth bearer token (HTTP #{e.http_code}): #{e.response&.body}"
|
|
125
237
|
rescue StandardError => e
|
|
126
238
|
raise "Failed to obtain Grok OAuth bearer token: #{e.message}"
|
|
127
239
|
end
|
|
@@ -137,28 +249,59 @@ module PWN
|
|
|
137
249
|
# timeout: 'optional timeout in seconds (defaults to 900)',
|
|
138
250
|
# spinner: 'optional - display spinner (defaults to false)'
|
|
139
251
|
# )
|
|
140
|
-
|
|
141
252
|
private_class_method def self.grok_rest_call(opts = {})
|
|
142
|
-
engine = PWN::Env
|
|
253
|
+
engine = PWN::Env.dig(:ai, :grok) if defined?(PWN::Env)
|
|
143
254
|
raise 'ERROR: Grok Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
|
|
144
255
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
# Bearer resolution (all sourced from PWN::Config / pwn-vault via
|
|
258
|
+
# PWN::Env[:ai][:grok]). Priority order:
|
|
259
|
+
# 1. oauth[:bearer_token] -> if present AND not expiring (JWT exp).
|
|
260
|
+
# 2. oauth[:refresh_token] -> silent refresh_token grant at auth.x.ai
|
|
261
|
+
# (writes new bearer back into oauth hash).
|
|
262
|
+
# 3. oauth device flow -> RFC 8628 enrollment via public Grok-CLI
|
|
263
|
+
# client. Triggered when the oauth section
|
|
264
|
+
# is opted-in (client_id set OR :enroll
|
|
265
|
+
# truthy) OR when no engine[:key] exists.
|
|
266
|
+
# 4. engine[:key] -> classic xAI API key.
|
|
267
|
+
# 5. interactive prompt -> last resort.
|
|
268
|
+
# Placeholder strings ("optional - ..." / "required - ...") written by
|
|
269
|
+
# PWN::Config.default_env are treated as UNSET.
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
oauth = engine[:oauth].is_a?(Hash) ? engine[:oauth] : (engine[:oauth] ||= {})
|
|
272
|
+
token = nil
|
|
273
|
+
|
|
274
|
+
token = oauth[:bearer_token] if real_config_value?(value: oauth[:bearer_token]) && !oauth_token_expiring?(token: oauth[:bearer_token])
|
|
275
|
+
|
|
276
|
+
if token.nil? && real_config_value?(value: oauth[:refresh_token])
|
|
277
|
+
begin
|
|
278
|
+
token = refresh_oauth_bearer_token(oauth)
|
|
279
|
+
rescue StandardError => e
|
|
280
|
+
warn "[!] Grok OAuth refresh failed, falling back: #{e.message}"
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
oauth_opt_in = real_config_value?(value: oauth[:client_id]) ||
|
|
285
|
+
oauth[:enroll] == true ||
|
|
286
|
+
real_config_value?(value: oauth[:bearer_token]) ||
|
|
287
|
+
real_config_value?(value: oauth[:refresh_token])
|
|
288
|
+
|
|
289
|
+
if token.nil? && (oauth_opt_in || !real_config_value?(value: engine[:key]))
|
|
290
|
+
# Singular device-flow enrollment. Result is written back into the
|
|
291
|
+
# live oauth hash so subsequent grok_rest_call invocations inside the
|
|
292
|
+
# same pwn / pwn-ai process reuse it silently.
|
|
153
293
|
token = obtain_oauth_bearer_token(oauth)
|
|
154
294
|
end
|
|
155
295
|
|
|
156
|
-
token
|
|
157
|
-
|
|
296
|
+
token = engine[:key] if token.nil? && real_config_value?(value: engine[:key])
|
|
297
|
+
|
|
298
|
+
token ||= PWN::Plugins::AuthenticationHelper.mask_password(
|
|
299
|
+
prompt: 'Grok API Key (or run PWN::AI::Grok.obtain_oauth_bearer_token for SuperGrok OAuth)'
|
|
300
|
+
)
|
|
158
301
|
|
|
159
|
-
http_method = opts[:http_method].to_s.scrub.to_sym
|
|
302
|
+
http_method = opts[:http_method].nil? ? :get : opts[:http_method].to_s.scrub.to_sym
|
|
160
303
|
|
|
161
|
-
base_uri = engine[:base_uri]
|
|
304
|
+
base_uri = real_config_value?(value: engine[:base_uri]) ? engine[:base_uri] : 'https://api.x.ai/v1'
|
|
162
305
|
rest_call = opts[:rest_call].to_s.scrub
|
|
163
306
|
params = opts[:params]
|
|
164
307
|
headers = {
|
data/lib/pwn/config.rb
CHANGED
|
@@ -40,11 +40,16 @@ module PWN
|
|
|
40
40
|
# OAuth support for xAI SuperGrok subscriptions (in addition to API key)
|
|
41
41
|
# Populate via pwn-vault command (values stored encrypted in ~/.pwn/pwn.yaml)
|
|
42
42
|
oauth: {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
# xAI Grok OAuth uses a PUBLIC client (Grok-CLI, same as hermes-agent) --
|
|
44
|
+
# NO client_secret. Run PWN::AI::Grok.obtain_oauth_bearer_token once
|
|
45
|
+
# (RFC 8628 device flow) then store refresh_token here; PWN refreshes
|
|
46
|
+
# the short-lived access_token automatically on every run.
|
|
47
|
+
refresh_token: 'optional - xAI SuperGrok OAuth Refresh Token (durable; enables silent re-auth)',
|
|
48
|
+
bearer_token: 'optional - xAI SuperGrok OAuth Access Token (short-lived JWT; auto-refreshed if refresh_token set)',
|
|
49
|
+
client_id: 'optional - override public Grok-CLI client_id (default: b1a00492-073a-47ea-816f-4c329264a828)',
|
|
50
|
+
scope: 'optional - override OAuth scope (default: openid profile email offline_access grok-cli:access api:access)',
|
|
51
|
+
token_uri: 'optional - override OAuth token endpoint (default: https://auth.x.ai/oauth2/token)',
|
|
52
|
+
enroll: 'optional - set true to force device-flow enrollment even when an API key is present'
|
|
48
53
|
}
|
|
49
54
|
},
|
|
50
55
|
openai: {
|
|
@@ -226,6 +231,7 @@ module PWN
|
|
|
226
231
|
pass
|
|
227
232
|
password
|
|
228
233
|
psk
|
|
234
|
+
refresh_token
|
|
229
235
|
secret_key
|
|
230
236
|
token
|
|
231
237
|
]
|
|
@@ -299,15 +305,35 @@ module PWN
|
|
|
299
305
|
engine = env[:ai][:active].to_s.downcase.to_sym
|
|
300
306
|
raise "ERROR: Unsupported AI Engine: #{engine} in #{pwn_env_path}. Supported AI Engines:\n#{valid_ai_engines.inspect}" unless valid_ai_engines.include?(engine)
|
|
301
307
|
|
|
308
|
+
# Determine whether the active engine already has usable auth
|
|
309
|
+
# material so the pwn / pwn-ai REPL driver does not prompt for an
|
|
310
|
+
# API key when OAuth is configured via pwn-vault.
|
|
311
|
+
#
|
|
312
|
+
# A value is considered "real" when it is non-blank AND is not one
|
|
313
|
+
# of the placeholder strings ("optional - ..." / "required - ...")
|
|
314
|
+
# written by PWN::Config.default_env into a fresh ~/.pwn/pwn.yaml.
|
|
315
|
+
real_cfg = lambda do |v|
|
|
316
|
+
s = v.to_s.strip
|
|
317
|
+
!s.empty? && !s.match?(/\A(optional|required)\b/i)
|
|
318
|
+
end
|
|
319
|
+
|
|
302
320
|
key = env[:ai][engine][:key]
|
|
303
|
-
|
|
321
|
+
key = nil unless real_cfg.call(key)
|
|
322
|
+
|
|
323
|
+
oauth_configured = false
|
|
304
324
|
if engine == :grok
|
|
305
|
-
oauth = env[:ai][engine][:oauth]
|
|
306
|
-
|
|
325
|
+
oauth = env[:ai][engine][:oauth]
|
|
326
|
+
oauth = env[:ai][engine][:oauth] = {} unless oauth.is_a?(Hash)
|
|
327
|
+
# OAuth is considered configured when either a bearer_token is
|
|
328
|
+
# stored (preferred, long-lived) OR client_id + client_secret are
|
|
329
|
+
# present (PWN::AI::Grok will run the singular enrollment flow).
|
|
330
|
+
oauth_configured = real_cfg.call(oauth[:bearer_token]) ||
|
|
331
|
+
(real_cfg.call(oauth[:client_id]) && real_cfg.call(oauth[:client_secret]))
|
|
307
332
|
end
|
|
308
|
-
|
|
333
|
+
|
|
334
|
+
if key.nil? && !oauth_configured
|
|
309
335
|
key = PWN::Plugins::AuthenticationHelper.mask_password(
|
|
310
|
-
prompt: "#{engine} API Key (or
|
|
336
|
+
prompt: "#{engine} API Key (or store ai.grok.oauth.refresh_token via pwn-vault -- run PWN::AI::Grok.obtain_oauth_bearer_token to enroll)"
|
|
311
337
|
)
|
|
312
338
|
env[:ai][engine][:key] = key
|
|
313
339
|
end
|
|
@@ -372,6 +398,53 @@ module PWN
|
|
|
372
398
|
File.join(File.dirname(pwn_env_path), 'skills')
|
|
373
399
|
end
|
|
374
400
|
|
|
401
|
+
# Supported Method Parameters::
|
|
402
|
+
# refs = PWN::Config.parse_skill_references(content: '...')
|
|
403
|
+
#
|
|
404
|
+
# Extracts an Array of reference strings (URLs, CWE/CVE/ATT&CK ids, etc.)
|
|
405
|
+
# from a skill body. Supports two formats:
|
|
406
|
+
# 1) YAML front-matter block: ---\nreferences:\n - https://...\n---\n
|
|
407
|
+
# 2) Markdown section: ## References\n- https://...\n
|
|
408
|
+
public_class_method def self.parse_skill_references(opts = {})
|
|
409
|
+
content = opts[:content].to_s
|
|
410
|
+
refs = []
|
|
411
|
+
|
|
412
|
+
# YAML front-matter (--- ... ---) at top of file
|
|
413
|
+
if content.start_with?("---\n")
|
|
414
|
+
fm_end = content.index("\n---", 4)
|
|
415
|
+
if fm_end
|
|
416
|
+
begin
|
|
417
|
+
require 'yaml'
|
|
418
|
+
fm = YAML.safe_load(content[4..fm_end], permitted_classes: [], aliases: false) || {}
|
|
419
|
+
r = fm['references'] || fm[:references]
|
|
420
|
+
refs.concat(Array(r).map(&:to_s)) if r
|
|
421
|
+
rescue StandardError
|
|
422
|
+
# ignore malformed front-matter
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Markdown "## References" section (bullets or bare lines until next heading / EOF)
|
|
428
|
+
if content =~ /^\s*\#{1,3}\s*References\s*$/i
|
|
429
|
+
in_section = false
|
|
430
|
+
content.each_line do |line|
|
|
431
|
+
if line =~ /^\s*\#{1,3}\s*References\s*$/i
|
|
432
|
+
in_section = true
|
|
433
|
+
next
|
|
434
|
+
end
|
|
435
|
+
next unless in_section
|
|
436
|
+
break if line =~ /^\s*\#{1,3}\s+\S/ # next heading
|
|
437
|
+
|
|
438
|
+
l = line.strip.sub(/^[-*]\s*/, '')
|
|
439
|
+
refs << l unless l.empty?
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
refs.map(&:strip).reject(&:empty?).uniq
|
|
444
|
+
rescue StandardError
|
|
445
|
+
[]
|
|
446
|
+
end
|
|
447
|
+
|
|
375
448
|
# Supported Method Parameters::
|
|
376
449
|
# skills = PWN::Config.load_skills(
|
|
377
450
|
# pwn_skills_path: 'optional - Path to skills folder. Defaults to ~/.pwn/skills'
|
|
@@ -396,12 +469,12 @@ module PWN
|
|
|
396
469
|
if ext == '.rb'
|
|
397
470
|
begin
|
|
398
471
|
require skill_file
|
|
399
|
-
skills[basename] = { type: :ruby, path: skill_file, content: content, loaded: true }
|
|
472
|
+
skills[basename] = { type: :ruby, path: skill_file, content: content, loaded: true, references: parse_skill_references(content: content) }
|
|
400
473
|
rescue StandardError => e
|
|
401
|
-
skills[basename] = { type: :ruby, path: skill_file, content: content, loaded: false, error: e.message }
|
|
474
|
+
skills[basename] = { type: :ruby, path: skill_file, content: content, loaded: false, error: e.message, references: parse_skill_references(content: content) }
|
|
402
475
|
end
|
|
403
476
|
else
|
|
404
|
-
skills[basename] = { type: :instruction, path: skill_file, content: content }
|
|
477
|
+
skills[basename] = { type: :instruction, path: skill_file, content: content, references: parse_skill_references(content: content) }
|
|
405
478
|
end
|
|
406
479
|
end
|
|
407
480
|
|
|
@@ -61,8 +61,8 @@ module PWN
|
|
|
61
61
|
begin
|
|
62
62
|
# unless process_command_safely(line)
|
|
63
63
|
unless process_command_safely(line) && (
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
line.empty? || @eval_string.empty?
|
|
65
|
+
)
|
|
66
66
|
# @eval_string += "#{line.chomp}\n" if !line.empty? || !@eval_string.empty?
|
|
67
67
|
@eval_string += "#{line.chomp}\n"
|
|
68
68
|
end
|