pwn 0.5.784 → 0.5.787

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.
Files changed (254) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +6 -6
  4. data/README.md +37 -0
  5. data/bin/pwn +2 -1
  6. data/bin/pwn_web_cache_deception +35 -7
  7. data/build_gem.sh +1 -1
  8. data/documentation/AI-Integration.md +7 -5
  9. data/documentation/Agent-Tool-Registry.md +2 -2
  10. data/documentation/Backup-Retention.md +69 -15
  11. data/documentation/Configuration.md +10 -6
  12. data/documentation/Contributing.md +1 -1
  13. data/documentation/Cron.md +2 -2
  14. data/documentation/Diagrams.md +2 -2
  15. data/documentation/General-PWN-Usage.md +4 -3
  16. data/documentation/Harness-Evaluation.md +1 -1
  17. data/documentation/Home.md +6 -5
  18. data/documentation/How-PWN-Works.md +6 -5
  19. data/documentation/Installation.md +12 -9
  20. data/documentation/Mission-Cancellation.md +134 -0
  21. data/documentation/Operability.md +18 -0
  22. data/documentation/Persistence.md +7 -5
  23. data/documentation/Plugin-Degradation.md +14 -0
  24. data/documentation/Plugins.md +3 -2
  25. data/documentation/Policy-Benchmark.md +32 -33
  26. data/documentation/Runtime-Source-Protection.md +162 -0
  27. data/documentation/Transparent-Browser.md +11 -0
  28. data/documentation/Troubleshooting.md +3 -2
  29. data/documentation/What-is-PWN.md +2 -2
  30. data/documentation/YubiKey-Vaults.md +185 -0
  31. data/documentation/YubiKey.md +218 -0
  32. data/documentation/diagrams/cron-scheduling.svg +130 -130
  33. data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
  34. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  35. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  36. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
  37. data/documentation/diagrams/dot/reinforcement-learning.dot +2 -2
  38. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  39. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  40. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +1 -1
  41. data/documentation/diagrams/reinforcement-learning.svg +89 -89
  42. data/documentation/pwn-ai-Agent.md +10 -4
  43. data/etc/default_cron/session_cleanup.rb +43 -27
  44. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  45. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  46. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  47. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  48. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  49. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  50. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  51. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  52. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  61. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  62. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  63. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  64. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  65. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  66. data/lib/pwn/ai/agent/learning.rb +14 -18
  67. data/lib/pwn/ai/agent/loop.rb +17 -1
  68. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  69. data/lib/pwn/ai/agent/policy_evaluation.rb +334 -36
  70. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  71. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  72. data/lib/pwn/ai/agent/reward.rb +5 -3
  73. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  74. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  75. data/lib/pwn/ai/agent/swarm.rb +4 -0
  76. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  77. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  79. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  80. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  81. data/lib/pwn/ai/agent.rb +1 -0
  82. data/lib/pwn/ai/anthropic.rb +8 -33
  83. data/lib/pwn/ai/cli.rb +34 -5
  84. data/lib/pwn/ai/gemini.rb +1 -0
  85. data/lib/pwn/ai/grok.rb +39 -28
  86. data/lib/pwn/ai/ollama.rb +4 -2
  87. data/lib/pwn/ai/open_ai.rb +11 -32
  88. data/lib/pwn/ai/open_web_ui.rb +62 -4
  89. data/lib/pwn/ai/router.rb +8 -1
  90. data/lib/pwn/ai/server_tools.rb +231 -0
  91. data/lib/pwn/ai.rb +1 -0
  92. data/lib/pwn/backups.rb +349 -0
  93. data/lib/pwn/config.rb +70 -39
  94. data/lib/pwn/migrate.rb +85 -280
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/google_workspace.rb +4 -21
  100. data/lib/pwn/plugins/jobs.rb +1 -1
  101. data/lib/pwn/plugins/log.rb +3 -1
  102. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  103. data/lib/pwn/plugins/recon.rb +4 -1
  104. data/lib/pwn/plugins/repl/ai.rb +293 -26
  105. data/lib/pwn/plugins/repl/ai_console.rb +268 -88
  106. data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
  107. data/lib/pwn/plugins/repl/mesh.rb +14 -19
  108. data/lib/pwn/plugins/repl/vault.rb +4 -15
  109. data/lib/pwn/plugins/repl.rb +9 -216
  110. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  111. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  112. data/lib/pwn/plugins/vault.rb +412 -135
  113. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  114. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  115. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  116. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  117. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  118. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  119. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  120. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  121. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  122. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  123. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  124. data/lib/pwn/plugins/yubikey.rb +844 -0
  125. data/lib/pwn/plugins.rb +1 -0
  126. data/lib/pwn/redaction.rb +69 -12
  127. data/lib/pwn/session_trace.rb +4 -0
  128. data/lib/pwn/setup.rb +114 -24
  129. data/lib/pwn/version.rb +1 -1
  130. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  131. data/lib/pwn/www/bug_crowd.rb +16 -5
  132. data/lib/pwn/www/coinbase_pro.rb +16 -5
  133. data/lib/pwn/www/github.rb +30 -14
  134. data/lib/pwn/www/paypal.rb +15 -4
  135. data/lib/pwn/www/synack.rb +16 -5
  136. data/lib/pwn/www/twitter.rb +17 -6
  137. data/lib/pwn.rb +1 -0
  138. data/spec/documentation/installation_md_spec.rb +1 -1
  139. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  140. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  141. data/spec/integration/controller_spill_spec.rb +48 -0
  142. data/spec/integration/harness_process_spec.rb +97 -0
  143. data/spec/integration/mission_cancel_spec.rb +79 -0
  144. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  145. data/spec/integration/readme_security_spec.rb +47 -0
  146. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  147. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  148. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  149. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  150. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  151. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  152. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  153. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  154. data/spec/lib/pwn/ai/agent/policy_evaluation_spec.rb +10 -23
  155. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  156. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  157. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  158. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  159. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  160. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  161. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  162. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  163. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  164. data/spec/lib/pwn/ai/cli_spec.rb +8 -16
  165. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  166. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  167. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  168. data/spec/lib/pwn/ai/router_spec.rb +1 -1
  169. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  170. data/spec/lib/pwn/backups_spec.rb +129 -0
  171. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  172. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  173. data/spec/lib/pwn/migrate_spec.rb +29 -81
  174. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  175. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  176. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  177. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  178. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  179. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  180. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  181. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  182. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  183. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  184. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  185. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
  186. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  187. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
  188. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  189. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  190. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  191. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  192. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  193. data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +2 -0
  194. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  195. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  196. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  197. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  198. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  199. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  200. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  201. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  202. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  203. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  204. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  205. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  206. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  207. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  208. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  209. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  210. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  211. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  212. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  213. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  214. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  215. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  216. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  217. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  218. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  219. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  220. data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
  221. data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
  222. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
  223. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  224. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  225. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  226. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  227. data/spec/spec_helper.rb +15 -0
  228. data/spec/support/harness/cli_faults.rb +13 -9
  229. data/spec/support/harness/controller_spill.rb +26 -0
  230. data/spec/support/harness/controller_state.rb +54 -0
  231. data/spec/support/harness/evaluate.rb +12 -1
  232. data/spec/support/harness/experiments.rb +1 -1
  233. data/spec/support/harness/fake_provider.rb +22 -6
  234. data/spec/support/harness/fault_preload.rb +3 -1
  235. data/spec/support/harness/mission_console.rb +54 -0
  236. data/spec/support/harness/mission_curses_pty.rb +41 -0
  237. data/spec/support/harness/mission_dispatch.rb +30 -0
  238. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  239. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  240. data/spec/support/harness/mission_legacy.rb +25 -0
  241. data/spec/support/harness/mission_normal_exit.rb +34 -0
  242. data/spec/support/harness/mission_swarm.rb +23 -0
  243. data/spec/support/harness/mission_unsupported.rb +29 -0
  244. data/spec/support/harness/package_acceptance.rb +33 -5
  245. data/spec/support/harness/session_acceptance.rb +4 -4
  246. data/spec/support/harness/swarm_acceptance.rb +1 -1
  247. data/spec/support/harness/swarm_control.rb +44 -0
  248. data/spec/support/mission_namespace.rb +14 -0
  249. data/spec/support/mission_pipe_queue.rb +33 -0
  250. data/third_party/pwn_rdoc.jsonl +605 -19
  251. metadata +129 -16
  252. data/scripts/benchmark_policy.rb +0 -376
  253. data/scripts/build_gate.rb +0 -58
  254. data/spec/integration/build_gate_spec.rb +0 -73
@@ -14,6 +14,236 @@ describe PWN::Plugins::AISandbox do
14
14
  expect(described_class).to respond_to :help
15
15
  end
16
16
 
17
+ it 'enforces read-only roots in real workers while preserving outside writes and host cwd' do
18
+ Dir.mktmpdir('pwn-boundary') do |root|
19
+ protected = File.join(root, 'source')
20
+ workspace = File.join(root, 'work')
21
+ FileUtils.mkdir_p([protected, workspace])
22
+ target = File.join(protected, 'keep')
23
+ File.write(target, 'original')
24
+ File.symlink(protected, File.join(workspace, 'alias'))
25
+ cwd = Dir.pwd
26
+ result = described_class.protect_call(roots: [protected], workspace: workspace, write_roots: [File.join(workspace, 'outside.txt')]) do
27
+ errors = [target, File.join(workspace, 'alias', 'keep'), File.join(protected, '..', 'source', 'new')].map do |path|
28
+ File.write(path, 'bad')
29
+ 'allowed'
30
+ rescue Errno::EACCES, Errno::EPERM
31
+ 'denied'
32
+ end
33
+ File.write('outside.txt', 'allowed')
34
+ { errors: errors, original: File.read(target), cwd: Dir.pwd }
35
+ end
36
+ expect(result).to include(errors: %w[denied denied denied], original: 'original', cwd: workspace)
37
+ expect(File.read(File.join(workspace, 'outside.txt'))).to eq('allowed')
38
+ expect(File.read(target)).to eq('original')
39
+ metadata = described_class.protect_call(roots: [protected], workspace: workspace) do
40
+ File.chmod(0o600, target)
41
+ { changed: true }
42
+ end
43
+ expect(metadata[:success]).to be false
44
+ expect(metadata[:error]).to match(/Read-only file system|Permission denied|Operation not permitted/)
45
+ expect(Dir.pwd).to eq(cwd)
46
+ end
47
+ end
48
+
49
+ it 'writes only in the runtime workspace unless the original request names another path' do
50
+ Dir.mktmpdir('pwn-boundary') do |root|
51
+ source = File.join(root, 'source')
52
+ workspace = File.join(root, 'runtime')
53
+ named = File.join(root, 'named', 'report.txt')
54
+ FileUtils.mkdir_p([source, workspace])
55
+ File.write(File.join(source, 'keep'), 'original')
56
+ outside = File.join(root, 'unstated.txt')
57
+ result = described_class.protect_call(roots: [source], workspace: workspace, write_roots: [named, File.join(source, 'keep')]) do
58
+ File.write(named, 'allowed')
59
+ unstated = begin
60
+ File.write(outside, 'no')
61
+ 'allowed'
62
+ rescue Errno::EACCES, Errno::EPERM
63
+ 'denied'
64
+ end
65
+ protected_write = begin
66
+ File.write(File.join(source, 'keep'), 'bad')
67
+ 'allowed'
68
+ rescue Errno::EACCES, Errno::EPERM
69
+ 'denied'
70
+ end
71
+ { named: File.read(named), unstated: unstated, protected_write: protected_write }
72
+ end
73
+ expect(result).to include(named: 'allowed', unstated: 'denied', protected_write: 'denied')
74
+ expect(File.read(File.join(source, 'keep'))).to eq('original')
75
+ expect(described_class.workspace).to eq(File.expand_path(File.join(Dir.home, '.pwn', 'workspaces', 'runtime')))
76
+ Dir.chdir(root) { expect(described_class.workspace).to eq(File.expand_path(File.join(Dir.home, '.pwn', 'workspaces', 'runtime'))) }
77
+ end
78
+ end
79
+
80
+ it 'allows mv onto an extensionless destination named by the original request' do
81
+ Dir.mktmpdir('pwn-boundary') do |root|
82
+ source = File.join(root, 'source')
83
+ workspace = File.join(root, 'runtime')
84
+ dest_dir = File.join(root, 'dest')
85
+ FileUtils.mkdir_p([source, workspace, dest_dir])
86
+ destination = File.join(dest_dir, 'test')
87
+ result = described_class.protect_call(roots: [source], workspace: workspace, write_roots: [workspace, destination]) do
88
+ File.write('foo', 'data')
89
+ moved = begin
90
+ File.rename('foo', destination)
91
+ 'allowed'
92
+ rescue Errno::EACCES, Errno::EPERM
93
+ 'denied'
94
+ end
95
+ { moved: moved, body: File.read(destination), still_dir: File.directory?(destination) }
96
+ end
97
+ expect(result).to include(moved: 'allowed', body: 'data', still_dir: false)
98
+ end
99
+ end
100
+
101
+ it 'creates a named file when the destination parent contains an unreadable directory' do
102
+ Dir.mktmpdir('pwn-boundary') do |root|
103
+ source = File.join(root, 'source')
104
+ workspace = File.join(root, 'runtime')
105
+ parent = File.join(root, 'dest')
106
+ hidden = File.join(parent, 'private')
107
+ destination = File.join(parent, 'test')
108
+ FileUtils.mkdir_p([source, workspace, parent, hidden])
109
+ File.chmod(0, hidden)
110
+ result = described_class.protect_call(roots: [source], workspace: workspace, write_roots: [destination]) do
111
+ created = begin
112
+ File.write(destination, 'data')
113
+ 'allowed'
114
+ rescue Errno::EACCES, Errno::EPERM
115
+ 'denied'
116
+ end
117
+ body = File.read(destination) if created == 'allowed'
118
+ { created: created, body: body }
119
+ end
120
+ expect(result).to include(created: 'allowed', body: 'data')
121
+ ensure
122
+ File.chmod(0o700, hidden) if hidden && File.directory?(hidden)
123
+ end
124
+ end
125
+
126
+ it 'fails closed on unsupported kernels and existing external hardlink aliases' do
127
+ Dir.mktmpdir('pwn-boundary') do |root|
128
+ source = File.join(root, 'source')
129
+ work = File.join(root, 'work')
130
+ FileUtils.mkdir_p([source, work])
131
+ path = File.join(source, 'protected')
132
+ File.write(path, 'original')
133
+ File.link(path, File.join(work, 'alias'))
134
+ result = described_class.protect_call(roots: [source], workspace: work) { File.write(File.join(work, 'alias'), 'bad') }
135
+ expect(result[:success]).to be false
136
+ expect(File.read(path)).to eq('original')
137
+ File.unlink(File.join(work, 'alias'))
138
+ allow(described_class).to receive(:repository_restrict!).and_raise(NotImplementedError, 'kernel unsupported')
139
+ expect { described_class.protect_call(roots: [source], workspace: work) { File.write(path, 'bad') } }.to raise_error(described_class::NotStarted, /kernel unsupported/)
140
+ expect(File.read(path)).to eq('original')
141
+ end
142
+ end
143
+
144
+ it 'keeps nested dispatch and detached subprocesses inside the inherited boundary' do
145
+ Dir.mktmpdir('pwn-boundary') do |root|
146
+ source = File.join(root, 'source')
147
+ work = File.join(root, 'work')
148
+ FileUtils.mkdir_p([source, work])
149
+ path = File.join(source, 'protected')
150
+ File.write(path, 'original')
151
+ jobs = File.join(work, 'jobs')
152
+ stub_const('PWN::Plugins::Jobs::JOBS_DIR', jobs)
153
+ result = described_class.protect_call(roots: [source], workspace: work, write_roots: [work]) do
154
+ PWN::AI::Agent::Registry.register(name: 'nested_boundary_fixture', toolset: 'test', schema: { name: 'nested_boundary_fixture', parameters: { type: 'object' } }, handler: ->(_args) { File.write(path, 'bad') })
155
+ nested = JSON.parse(PWN::AI::Agent::Dispatch.call(tool_call: { function: { name: 'nested_boundary_fixture', arguments: '{}' } }))
156
+ job = PWN::Plugins::Jobs.start(command: "printf bad > #{path}; printf done > #{work}/done", max_runtime: 10)
157
+ { nested: nested, job: job }
158
+ end
159
+ expect(result.dig(:nested, :success)).to be(false), result.inspect
160
+ expect(result.dig(:job, :id)).to be_a(String), result.inspect
161
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 15
162
+ sleep 0.02 until File.exist?(File.join(work, 'done')) || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
163
+ expect(File.read(File.join(work, 'done'))).to eq('done')
164
+ expect(File.read(path)).to eq('original')
165
+ end
166
+ end
167
+
168
+ it 'protects controller writes after a worker redirects checkpoint ancestry' do
169
+ Dir.mktmpdir('pwn-controller-boundary') do |root|
170
+ source = File.join(root, 'source')
171
+ work = File.join(root, 'work')
172
+ FileUtils.mkdir_p([source, work])
173
+ script = <<~RUBY
174
+ require 'pwn'
175
+ require 'json'
176
+ source, work = ARGV
177
+ PWN::Plugins::AISandbox.define_singleton_method(:protected_roots) { [source, '/opt/pwn'] }
178
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: 'Create a report', session_id: 'boundary', artifact_root: File.join(work, 'state'), trace: false, retrieve: false)
179
+ Thread.current[:pwn_request_runtime] = runtime
180
+ directory = File.dirname(runtime.checkpoint_path)
181
+ PWN::AI::Agent::Registry.register(name: 'boundary_fixture', toolset: 'fixture', schema: { name: 'boundary_fixture', parameters: { type: 'object' } }, handler: lambda { |_args|
182
+ FileUtils.rm_rf(directory)
183
+ File.symlink(source, directory)
184
+ { redirected: true }
185
+ })
186
+ result = JSON.parse(PWN::AI::Agent::Dispatch.call(tool_call: { function: { name: 'boundary_fixture', arguments: '{}' } }))
187
+ puts JSON.generate(changed: !Dir.children(source).empty?, result: result)
188
+ RUBY
189
+ command = [RbConfig.ruby, '-I', File.expand_path('lib'), '-e', script, source, work]
190
+ stdout, stderr, status = Open3.capture3({ 'HOME' => work }, *command)
191
+ expect(status.success?).to be(true), stderr
192
+ row = JSON.parse(stdout)
193
+ expect(row['changed']).to be(false), row.inspect
194
+ expect(row.dig('result', 'success')).to be(false)
195
+ expect(Dir.children(source)).to be_empty
196
+ end
197
+ end
198
+
199
+ it 'brokers actual spawn and ask without granting state redirection or handler bypass' do
200
+ Dir.mktmpdir('pwn-swarm-control') do |root|
201
+ work = File.join(root, 'work')
202
+ home = File.join(root, 'home')
203
+ FileUtils.mkdir_p([work, home])
204
+ tool = lambda do |command, id|
205
+ { role: 'assistant', content: '', tool_calls: [{ id: id, type: 'function', function: { name: 'shell', arguments: JSON.generate(command: command, timeout: 10) } }] }
206
+ end
207
+ fixture = File.join(work, 'provider.json')
208
+ File.write(fixture, JSON.generate(toolsets: %w[shell swarm], responses: [tool.call("printf 'alpha\\n' > result.txt", 'write'), tool.call('cat result.txt', 'read')] + Array.new(8) { { role: 'assistant', content: 'FIXTURE_COMPLETE', tool_calls: [] } }))
209
+ env = { 'HOME' => home, 'PWN_HARNESS_FIXTURE' => fixture, 'PWN_HARNESS_EVENTS' => File.join(work, 'events.jsonl'), 'PWN_HARNESS_FINGERPRINT' => File.join(work, 'fingerprint.json') }
210
+ command = [RbConfig.ruby, '-I', File.expand_path('lib'), '-r', File.expand_path('spec/support/harness/fake_provider.rb'), File.expand_path('spec/support/harness/swarm_control.rb'), work]
211
+ stdout, stderr, status = Open3.capture3(env, *command, chdir: work)
212
+ expect(status.success?).to be(true), "#{stderr}\n#{stdout}"
213
+ row = JSON.parse(stdout.lines.last)
214
+ expect(row.dig('spawn', 'success')).to be(true), row.inspect
215
+ expect(row.dig('ask', 'success')).to be(true), row.inspect
216
+ expect(row.dig('ask', 'result', 'reply')).to eq('FIXTURE_COMPLETE')
217
+ expect(row['bytes']).to eq("alpha\n")
218
+ expect(row['sentinel']).to eq('unchanged')
219
+ expect(row.dig('attack', 'result').values).to all(start_with('denied:'))
220
+ expect(row.dig('replacement', 'success')).to be(false)
221
+ expect(row.dig('observations', File.join(work, 'result.txt'), 'child_request_id')).to be_a(String)
222
+ persisted = YAML.safe_load_file(File.join(home, '.pwn', 'agents.yml'))
223
+ expect(persisted.fetch('fixture_worker').fetch('model')).to eq('offline-scripted-v1')
224
+ end
225
+ end
226
+
227
+ it 'pins controller directories across pathname replacement and rejects source ancestry' do
228
+ Dir.mktmpdir('pwn-control-fd') do |root|
229
+ source = File.join(root, 'source')
230
+ state = File.join(root, 'state')
231
+ moved = File.join(root, 'moved')
232
+ FileUtils.mkdir_p([source, state])
233
+ allow(described_class).to receive(:protected_roots).and_return([source])
234
+ described_class.control_directory(path: state) do |fd_path|
235
+ File.rename(state, moved)
236
+ File.symlink(source, state)
237
+ File.open(File.join(fd_path, 'receipt'), File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write('safe') }
238
+ end
239
+ expect(File.read(File.join(moved, 'receipt'))).to eq('safe')
240
+ expect(Dir.children(source)).to be_empty
241
+ expect { described_class.control_directory(path: state) { raise 'must not run' } }.to raise_error(Errno::ENOTDIR)
242
+ expect { described_class.control_directory(path: File.join(source, 'new')) { raise 'must not run' } }.to raise_error(described_class::NotStarted, /protected source/)
243
+ expect(Dir.children(source)).to be_empty
244
+ end
245
+ end
246
+
17
247
  it 'denies rm -rf /tmp/fake_root in strict mode without touching disk' do
18
248
  root = '/tmp/fake_root'
19
249
  FileUtils.mkdir_p(root)
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PWN::Plugins::AuthenticationHelper, 'native YubiKey validation' do
6
+ before do
7
+ stub_const('PWN::Plugins::YubiKey', double('offline native device'))
8
+ expect(TTY::Prompt).not_to receive(:new)
9
+ end
10
+
11
+ [{ ykman: '/fixture/unsupported' }, { timeout: false }, { timeout: 0 }, { timeout: Float::INFINITY },
12
+ { reader: false }, { reader: '' }, { reader: "bad\nreader" }, { device_path: false }, { device_path: '' },
13
+ { device_path: "bad\0path" }, { serial: 123 }, { serial: false }, { serial: ' 123' },
14
+ { serial: '123', reader: 'Fixture reader' }, { reader: 'Fixture reader', device_path: '/fixture/native-device' }, { connection: false }, { connection: Object.new },
15
+ { password: false }, { password: '' }, { secret_provider: false }, { secret_provider: Object.new }].each do |invalid|
16
+ it "rejects #{invalid.keys.join('/')} controls before login, prompts or a device operation" do
17
+ selector = { account: 'Fixture:account', confirm: true }.merge(invalid)
18
+ expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
19
+ expect { described_class.validate_mfa_options(yubikey: selector) }.to raise_error(ArgumentError, /yubikey/i)
20
+ expect { described_class.mfa(yubikey: selector) }.to raise_error(ArgumentError, /yubikey/i)
21
+ end
22
+ end
23
+
24
+ it 'forwards an exact native connection and in-memory password without inspecting or persisting them' do
25
+ connection = double('native connection', exchange: ''.b)
26
+ selector = { account: ' Fixture:account ', confirm: true, reader: ' Fixture reader ', connection: connection, password: 'fixture-only-password' }.freeze
27
+ expect(PWN::Plugins::YubiKey).to receive(:oath_code).with(selector).and_return('000123')
28
+ expect(described_class.mfa(yubikey: selector)).to eq('000123')
29
+ end
30
+
31
+ it 'does not invoke a secret provider during source selection and passes it unchanged to the native helper' do
32
+ provider = double('operator secret provider', call: 'fixture-only-password')
33
+ expect(provider).not_to receive(:call)
34
+ selector = { account: 'Fixture:account', confirm: true, serial: '001234', device_path: '/fixture/native-device', secret_provider: provider }.freeze
35
+ options = { yubikey: selector }.freeze
36
+ expect(described_class.validate_mfa_options(options)).to equal(options)
37
+ expect(PWN::Plugins::YubiKey).to receive(:oath_code).with(selector).and_return('00123456')
38
+ expect(described_class.mfa(options)).to eq('00123456')
39
+ end
40
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PWN::Plugins::AuthenticationHelper do
6
+ let(:yubikey) { { account: 'Example:tester@example.invalid', confirm: true, serial: '123456', device_path: '/fixture/native-device', timeout: 30 }.freeze }
7
+
8
+ before do
9
+ stub_const('PWN::Plugins::YubiKey', Module.new)
10
+ allow(PWN::Plugins::YubiKey).to receive(:oath_code).and_return('012345')
11
+ allow(TTY::Prompt).to receive(:new).and_raise('unexpected interactive prompt')
12
+ end
13
+
14
+ it 'returns the YubiKey OATH digits without removing a leading zero or prompting' do
15
+ expect(PWN::Plugins::YubiKey).to receive(:oath_code).with(yubikey).and_return('012345')
16
+ expect(described_class.mfa(yubikey: yubikey)).to eq('012345')
17
+ end
18
+
19
+ [false, true, 'Example', [], {}, { account: nil }, { account: 123 }, { account: '' }, { account: ' ' }, { account: "Example\nOther" }].each do |invalid|
20
+ it "rejects invalid YubiKey options #{invalid.inspect} without a device call or prompt" do
21
+ expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
22
+ expect { described_class.mfa(yubikey: invalid) }.to raise_error(ArgumentError, /yubikey|account/)
23
+ end
24
+ end
25
+
26
+ it 'preserves the exact account name and caller options' do
27
+ exact = { account: ' Example:tester@example.invalid ', confirm: true }.freeze
28
+ expect(PWN::Plugins::YubiKey).to receive(:oath_code).with(exact).and_return('00123456')
29
+ expect(described_class.mfa(yubikey: exact)).to eq('00123456')
30
+ end
31
+
32
+ it 'propagates a YubiKey failure without falling back to an interactive prompt' do
33
+ allow(PWN::Plugins::YubiKey).to receive(:oath_code).and_raise(IOError, 'device unavailable')
34
+ expect { described_class.mfa(yubikey: yubikey) }.to raise_error(IOError, 'device unavailable')
35
+ end
36
+
37
+ it 'rejects an OTP slot instead of treating it as an OATH credential' do
38
+ expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
39
+ expect { described_class.mfa(yubikey: yubikey.merge(slot: 2)) }.to raise_error(ArgumentError, /slot.*OATH/)
40
+ end
41
+
42
+ it 'propagates device cancellation instead of returning a value for a form' do
43
+ allow(PWN::Plugins::YubiKey).to receive(:oath_code).and_raise(Interrupt)
44
+ expect { described_class.mfa(yubikey: yubikey) }.to raise_error(Interrupt)
45
+ end
46
+
47
+ [nil, false, 'true', 1].each do |confirm|
48
+ it "rejects confirm #{confirm.inspect} before a device call or prompt" do
49
+ expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
50
+ expect { described_class.mfa(yubikey: yubikey.merge(confirm: confirm)) }.to raise_error(ArgumentError, /confirm: true.*HOTP.*authenticat/i)
51
+ end
52
+ end
53
+
54
+ it 'requires explicit consent during pre-login validation without modifying options' do
55
+ options = { yubikey: yubikey.except(:confirm).freeze }.freeze
56
+ expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
57
+ expect { described_class.validate_mfa_options(options) }.to raise_error(ArgumentError, /confirm: true/)
58
+ expect(options[:yubikey]).not_to have_key(:confirm)
59
+ end
60
+
61
+ it 'does not accept string-keyed consent as the literal confirm option' do
62
+ expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
63
+ expect { described_class.mfa(yubikey: yubikey.except(:confirm).merge('confirm' => true)) }.to raise_error(ArgumentError, /confirm: true/)
64
+ end
65
+
66
+ it 'keeps malformed-account and OTP-slot errors ahead of missing consent' do
67
+ expect { described_class.validate_mfa_options(yubikey: { account: '' }) }.to raise_error(ArgumentError, /account must/)
68
+ expect { described_class.validate_mfa_options(yubikey: { slot: 2 }) }.to raise_error(ArgumentError, /slot.*OATH/)
69
+ end
70
+
71
+ [{ mfa: '654321' }, { mfa_token: '654321' }].each do |conflict|
72
+ it "keeps the #{conflict.keys.first} conflict ahead of missing consent" do
73
+ expect { described_class.validate_mfa_options(conflict.merge(yubikey: yubikey.except(:confirm))) }.to raise_error(ArgumentError, /combined.*mfa.*mfa_token/)
74
+ end
75
+ end
76
+
77
+ it 'documents exact OATH selection, optional device controls, and fail-closed behavior' do
78
+ expect { described_class.help }.to output(/yubikey:.*exact.*account.*serial.*reader.*device_path.*connection.*timeout.*password.*secret_provider.*no OTP slot.*validate_mfa_options/m).to_stdout
79
+ expect { described_class.help }.to output(/Example yubikey: \{ account: 'EXACT_OATH_NAME', confirm: true.*HOTP.*authenticat/m).to_stdout
80
+ expect { described_class.help }.not_to output(/ykman/).to_stdout
81
+ end
82
+
83
+ it 'preserves the manual prompt when YubiKey is not selected' do
84
+ prompt = instance_double(TTY::Prompt)
85
+ allow(TTY::Prompt).to receive(:new).and_return(prompt)
86
+ expect(prompt).to receive(:ask).with('Test MFA: ').and_return(" 012345\n")
87
+ expect(PWN::Plugins::YubiKey).not_to receive(:oath_code)
88
+ expect(described_class.mfa(prompt: 'Test MFA')).to eq('012345')
89
+ end
90
+ end
@@ -158,7 +158,7 @@ describe 'Native capability broker daemon' do
158
158
  status = File.read("/proc/#{pid}/status")
159
159
  expect(status[/^Uid:\s+(.+)/, 1].split.map(&:to_i)).to eq([Process.uid] * 4)
160
160
  expect(status[/^Gid:\s+(.+)/, 1].split.map(&:to_i)).to eq([Process.gid] * 4)
161
- expect(status[/^Groups:[^\S\n]*([^\n]*)/, 1].split.map(&:to_i)).to eq(Process.groups.sort)
161
+ expect(status[/^Groups:[^\S\n]*([^\n]*)/, 1].split.map(&:to_i).sort).to eq(Process.groups.sort)
162
162
  expect(status[/^NoNewPrivs:\s+(\d+)/, 1]).to eq('1')
163
163
  expect(status[/^CapEff:\s+(\h+)/, 1].to_i(16) & ~((1 << 12) | (1 << 13))).to eq(0)
164
164
  expect(status[/^CapPrm:\s+(\h+)/, 1].to_i(16) & ~((1 << 12) | (1 << 13))).to eq(0)
@@ -86,34 +86,23 @@ describe PWN::Plugins::GoogleWorkspace do
86
86
  Dir.mktmpdir('pwn-gw-oauth-') do |dir|
87
87
  env_path = File.join(dir, 'pwn.yaml')
88
88
  dec_path = "#{env_path}.decryptor"
89
- key = '0' * 32
90
- iv = '1' * 16
91
- File.write(env_path, "plugins:\n google_workspace:\n oauth:\n client_id: cid\n client_secret: sec\n")
92
- File.write(dec_path, YAML.dump(key: key, iv: iv))
93
- allow(PWN::Plugins::Vault).to receive(:decrypt)
94
- allow(PWN::Plugins::Vault).to receive(:encrypt)
89
+ stub_const('PWN::Plugins::GoogleWorkspace::PENDING_FILE', File.join(dir, 'pending.json'))
90
+ stub_const('PWN::Env', {
91
+ plugins: { google_workspace: { oauth: { client_id: 'cid', client_secret: 'sec' } } },
92
+ driver_opts: { pwn_env_path: env_path, pwn_dec_path: dec_path }
93
+ })
94
+ File.write(env_path, YAML.dump(PWN::Env))
95
+ PWN::Plugins::Vault.create(file: env_path, decryptor_file: dec_path)
96
+ credentials = YAML.safe_load_file(dec_path, symbolize_names: true)
95
97
  allow(RestClient).to receive(:post).and_return(
96
98
  instance_double(RestClient::Response, body: {
97
- access_token: 'live-bearer',
98
- refresh_token: 'live-refresh',
99
+ access_token: 'fixture-bearer',
100
+ refresh_token: 'fixture-refresh',
99
101
  expires_in: 3600,
100
102
  scope: 'https://www.googleapis.com/auth/gmail.modify'
101
103
  }.to_json)
102
104
  )
103
105
 
104
- PWN::Env[:plugins] ||= {}
105
- PWN::Env[:plugins][:google_workspace] ||= {}
106
- PWN::Env[:plugins][:google_workspace][:oauth] ||= {}
107
- slot = PWN::Env[:plugins][:google_workspace][:oauth]
108
- prev = slot.dup
109
- slot[:client_id] = 'cid'
110
- slot[:client_secret] = 'sec'
111
- slot[:bearer_token] = nil
112
- slot[:refresh_token] = nil
113
- PWN::Env[:driver_opts] ||= {}
114
- PWN::Env[:driver_opts][:pwn_env_path] = env_path
115
- PWN::Env[:driver_opts][:pwn_dec_path] = dec_path
116
-
117
106
  result = described_class.obtain_oauth_auth_url(
118
107
  client_id: 'cid',
119
108
  client_secret: 'sec',
@@ -122,20 +111,13 @@ describe PWN::Plugins::GoogleWorkspace do
122
111
  probe: ->(_redirect) { 'AUTHCODE' }
123
112
  )
124
113
 
125
- expect(result[:bearer_token]).to eq('live-bearer')
126
- expect(result[:refresh_token]).to eq('live-refresh')
127
- expect(slot[:bearer_token]).to eq('live-bearer')
128
- expect(slot[:refresh_token]).to eq('live-refresh')
129
- vault = YAML.load_file(env_path, symbolize_names: true)
130
- expect(vault.dig(:plugins, :google_workspace, :oauth, :bearer_token)).to eq('live-bearer')
131
- expect(vault.dig(:plugins, :google_workspace, :oauth, :refresh_token)).to eq('live-refresh')
132
- ensure
133
- if prev
134
- slot.clear
135
- slot.merge!(prev)
136
- end
137
- PWN::Env[:driver_opts].delete(:pwn_env_path) if PWN::Env[:driver_opts].is_a?(Hash)
138
- PWN::Env[:driver_opts].delete(:pwn_dec_path) if PWN::Env[:driver_opts].is_a?(Hash)
114
+ expect(result[:bearer_token]).to eq('fixture-bearer')
115
+ expect(result[:refresh_token]).to eq('fixture-refresh')
116
+ expect(PWN::Env.dig(:plugins, :google_workspace, :oauth)).to include(bearer_token: 'fixture-bearer', refresh_token: 'fixture-refresh')
117
+ vault = PWN::Plugins::Vault.dump({ file: env_path }.merge(credentials))
118
+ expect(vault.dig(:plugins, :google_workspace, :oauth)).to include(bearer_token: 'fixture-bearer', refresh_token: 'fixture-refresh')
119
+ expect(PWN::Plugins::Vault.file_encrypted?(file: env_path)).to be(true)
120
+ expect(File.stat(env_path).mode & 0o777).to eq(0o600)
139
121
  end
140
122
  end
141
123
 
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe PWN::Plugins::Log, 'hardware credential logging' do
7
+ it 'masks sensitive trace keys before the general credential digest path' do
8
+ fields = %i[pin puk access_code management_key oath_secret static_password scp_keys mfa_code]
9
+ fields.each do |key|
10
+ expect(described_class.send(:inspect_debug, key: key, value: '123456')).to eq('[REDACTED]')
11
+ expect(described_class.send(:inspect_debug, key: key, value: '654321')).to eq('[REDACTED]')
12
+ end
13
+ expect(described_class.send(:inspect_debug, key: :api_key, value: 'fixture-api')).to eq(PWN::Redaction.token(kind: 'credential', value: 'fixture-api'))
14
+ end
15
+
16
+ it 'redacts nested structured debug values without consulting the credential store' do
17
+ expect(PWN::Plugins::Vault).not_to receive(:redact)
18
+ expect(PWN::Plugins::Vault).not_to receive(:expand)
19
+ expect(PWN::Plugins::Vault).not_to receive(:fetch)
20
+ input = { selector: { slot: 2, serial: '12345678' }, nested: [{ pin: '123456', scp_keys: %w[fixture-enc fixture-mac] }] }
21
+ text = described_class.send(:inspect_debug, value: input)
22
+ expect(text).to include('slot: 2', 'serial: "12345678"', '[REDACTED]')
23
+ expect(text).not_to include('pin: "123456"', 'fixture-enc', 'fixture-mac', '[REDACTED:credential:')
24
+ expect(input[:nested].first[:pin]).to eq('123456')
25
+ end
26
+
27
+ it 'writes only constant masks for PIN and management fields to log, debug, mirror and stderr sinks' do
28
+ Dir.mktmpdir('pwn-log-yubikey-') do |dir|
29
+ allow(Dir).to receive(:home).and_return(dir)
30
+ fixture = 'pin=123456 management_key=fixture-management mfa_code=654321'
31
+ described_class.append(level: :info, msg: { pin: '123456', management_key: 'fixture-management' })
32
+ path = File.join(dir, 'debug.log')
33
+ described_class.start_debug(path: path)
34
+ described_class.progress(msg: fixture)
35
+ described_class.mirror_tui!(msg: fixture)
36
+ described_class.capture_stderr!(text: "#{fixture}\n")
37
+ described_class.stop_debug
38
+ [path, File.join(dir, '.pwn', 'logs', 'pwn.log')].each do |file|
39
+ body = File.read(file)
40
+ expect(body).to include('[REDACTED]')
41
+ expect(body).not_to include('123456', '654321', 'fixture-management', '[REDACTED:')
42
+ end
43
+ ensure
44
+ described_class.stop_debug
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PWN::Plugins::PreflightChecker, 'native YubiKey facade contract' do
6
+ it 'routes per-application host-only probes through the real facade without opening or enumerating a device' do
7
+ device = PWN::Plugins::YubiKey
8
+ transport = device::Transport
9
+ allow(transport).to receive(:available?).with({ application: :fido }).and_return(true)
10
+ allow(transport).to receive(:available?).with({ application: :oath }).and_return(true)
11
+ expect(transport).not_to receive(:devices)
12
+ expect(transport).not_to receive(:open)
13
+ %i[devices info command challenge_response oath_code].each { |method| expect(device).not_to receive(method) }
14
+ row = described_class.check(names: ['PWN::Plugins::YubiKey']).first
15
+ expect(row).to include(status: :ok, missing_bins: [], missing_native: [])
16
+ expect(transport).to have_received(:available?).with({ application: :fido }).once
17
+ expect(transport).to have_received(:available?).with({ application: :oath }).once
18
+ end
19
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PWN::Plugins::PreflightChecker, 'YubiKey dependency reporting' do
6
+ let(:names) { ['PWN::Plugins::YubiKey'] }
7
+
8
+ before do
9
+ @device = double('unopened YubiKey')
10
+ stub_const('PWN::Plugins::YubiKey', @device)
11
+ %i[devices info command challenge_response oath_code].each { |method| expect(@device).not_to receive(method) }
12
+ allow(@device).to receive(:available?).with({ application: :fido }).and_return(false)
13
+ allow(@device).to receive(:available?).with({ application: :oath }).and_return(false)
14
+ allow(described_class).to receive(:bin?).and_return(true)
15
+ expect(described_class).not_to receive(:bin?).with(name: 'ykman')
16
+ end
17
+
18
+ it 'reports unavailable native host libraries without a binary requirement or device probe' do
19
+ row = described_class.check(names: names).first
20
+ expect(row).to include(plugin: names.first, status: :dead, missing_bins: [], missing_caps: [], missing_services: [])
21
+ expect(row[:missing_native]).to eq(['FIDO native transport', 'PC/SC native transport'])
22
+ expect(described_class.host_summary(names: names)).to include('PC/SC native transport', 'documentation/Plugin-Degradation.md#pwn-plugins-yubikey')
23
+ end
24
+
25
+ it 'reports partial native host capability separately from all libraries being available' do
26
+ allow(@device).to receive(:available?).with({ application: :fido }).and_return(true)
27
+ expect(described_class.check(names: names).first).to include(status: :degraded, missing_native: ['PC/SC native transport'])
28
+ allow(@device).to receive(:available?).with({ application: :oath }).and_return(true)
29
+ expect(described_class.check(names: names).first).to include(status: :ok, missing_bins: [], missing_native: [])
30
+ end
31
+
32
+ it 'preserves rows for plugins without declared native dependencies' do
33
+ expected = { plugin: 'PWN::Plugins::NmapIt', status: :ok, missing_bins: [], missing_caps: [], missing_services: [] }
34
+ expect(described_class.check(names: ['PWN::Plugins::NmapIt'])).to eq([expected])
35
+ end
36
+
37
+ it 'includes native dependency status in the manifest without changing other plugin rows' do
38
+ Dir.mktmpdir('native-preflight-') do |root|
39
+ allow(Dir).to receive(:home).and_return(root)
40
+ result = described_class.manifest
41
+ expect(result[:plugins].fetch(names.first)).to include(ok: false, missing: [], missing_native: ['FIDO native transport', 'PC/SC native transport'])
42
+ expect(result[:plugins].fetch('PWN::Plugins::NmapIt')).to eq(bins: ['nmap'], missing: [], ok: true, caps: [])
43
+ stored = JSON.parse(File.read(result.fetch(:path)))
44
+ expect(stored.fetch('plugins').fetch(names.first).fetch('missing_native')).to eq(result[:plugins][names.first][:missing_native])
45
+ end
46
+ end
47
+
48
+ it 'treats load or dependency errors as a missing host library without exposing backend messages' do
49
+ [LoadError, IOError].each do |error|
50
+ allow(@device).to receive(:available?).with({ application: :oath }).and_raise(error, 'private fixture path')
51
+ expect(described_class.check(names: names).first).to include(status: :dead, missing_native: ['FIDO native transport', 'PC/SC native transport'])
52
+ expect(described_class.host_summary(names: names)).not_to include('private fixture path')
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe PWN::Plugins::Recon, 'hardware loot capture' do
7
+ it 'keeps hardware failures visible without creating software loot or a master key' do
8
+ Dir.mktmpdir('pwn-recon-yubikey-failure-') do |root|
9
+ allow(Dir).to receive(:home).and_return(root)
10
+ device = double('offline unavailable YubiKey')
11
+ stub_const('PWN::Plugins::YubiKey', device)
12
+ allow(device).to receive(:challenge_response).and_raise(IOError, 'fixture device unavailable')
13
+ assets = [{ address: 'app.example.test', observations: [{ banner: 'password=fixture-password' }] }]
14
+ expect { described_class.harvest_loot(assets: assets, engagement_id: 'lab', yubikey: { slot: 2 }) }.to raise_error(IOError, 'fixture device unavailable')
15
+ expect(File).not_to exist(File.join(root, '.pwn-vault.key'))
16
+ expect(PWN::Plugins::Vault.query(engagement: 'lab')).to be_empty
17
+ end
18
+ end
19
+
20
+ it 'forwards the explicit selector through run and harvest into real hardware-encrypted loot' do
21
+ Dir.mktmpdir('pwn-recon-yubikey-') do |root|
22
+ allow(Dir).to receive(:home).and_return(root)
23
+ allow(PWN::AI::Agent::Mission).to receive(:active_id).and_return(nil)
24
+ device = double('offline YubiKey fixture')
25
+ stub_const('PWN::Plugins::YubiKey', device)
26
+ selector = { slot: 2, serial: '12345678', timeout: 4, device_path: '/fixture/native-device' }
27
+ allow(device).to receive(:challenge_response) do |opts|
28
+ expect(opts).to include(selector)
29
+ OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts.fetch(:challenge))
30
+ end
31
+ socket = double('offline banner', readpartial: "username=fixture-user\npassword=fixture-password\n", remote_address: double(ip_address: '192.0.2.10'))
32
+ allow(Socket).to receive(:tcp).and_yield(socket)
33
+ result = described_class.run(target: 'app.example.test', modules: ['banner'], ports: [22], engagement_id: 'lab', root: root, yubikey: selector)
34
+ expect(File).not_to exist(File.join(root, '.pwn-vault.key'))
35
+ expect(device).to have_received(:challenge_response).once
36
+ rows = JSON.parse(File.read(File.join(root, '.pwn', 'engagements', 'lab', 'loot.json'))).fetch('records')
37
+ expect(rows.first.dig('secret', 'kdf')).to eq('yubikey-hmac-sha1-hkdf-sha256')
38
+ expect(rows.first.dig('secret', 'yubikey')).to include('slot' => 2, 'serial' => '12345678')
39
+ expect(result[:loot].first).to include(engagement: 'lab', source: 'recon', where: 'banner:22', host: '192.0.2.10')
40
+ expect(PWN::Plugins::Vault.fetch(label: result[:loot].first[:label], engagement: 'lab', yubikey: selector)).to eq('fixture-password')
41
+ end
42
+ end
43
+ end