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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/Gemfile +6 -6
- data/README.md +37 -0
- data/bin/pwn +2 -1
- data/bin/pwn_web_cache_deception +35 -7
- data/build_gem.sh +1 -1
- data/documentation/AI-Integration.md +7 -5
- data/documentation/Agent-Tool-Registry.md +2 -2
- data/documentation/Backup-Retention.md +69 -15
- data/documentation/Configuration.md +10 -6
- data/documentation/Contributing.md +1 -1
- data/documentation/Cron.md +2 -2
- data/documentation/Diagrams.md +2 -2
- data/documentation/General-PWN-Usage.md +4 -3
- data/documentation/Harness-Evaluation.md +1 -1
- data/documentation/Home.md +6 -5
- data/documentation/How-PWN-Works.md +6 -5
- data/documentation/Installation.md +12 -9
- data/documentation/Mission-Cancellation.md +134 -0
- data/documentation/Operability.md +18 -0
- data/documentation/Persistence.md +7 -5
- data/documentation/Plugin-Degradation.md +14 -0
- data/documentation/Plugins.md +3 -2
- data/documentation/Policy-Benchmark.md +32 -33
- data/documentation/Runtime-Source-Protection.md +162 -0
- data/documentation/Transparent-Browser.md +11 -0
- data/documentation/Troubleshooting.md +3 -2
- data/documentation/What-is-PWN.md +2 -2
- data/documentation/YubiKey-Vaults.md +185 -0
- data/documentation/YubiKey.md +218 -0
- data/documentation/diagrams/cron-scheduling.svg +130 -130
- data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
- data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
- data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
- data/documentation/diagrams/dot/reinforcement-learning.dot +2 -2
- data/documentation/diagrams/persistence-filesystem.svg +172 -142
- data/documentation/diagrams/plugin-ecosystem.svg +64 -64
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +1 -1
- data/documentation/diagrams/reinforcement-learning.svg +89 -89
- data/documentation/pwn-ai-Agent.md +10 -4
- data/etc/default_cron/session_cleanup.rb +43 -27
- data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
- data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
- data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
- data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
- data/etc/default_skills/pwn/backups/SKILL.md +54 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
- data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
- data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
- data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
- data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
- data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
- data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
- data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
- data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
- data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
- data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
- data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
- data/lib/pwn/ai/agent/dispatch.rb +26 -15
- data/lib/pwn/ai/agent/learning.rb +14 -18
- data/lib/pwn/ai/agent/loop.rb +17 -1
- data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
- data/lib/pwn/ai/agent/policy_evaluation.rb +334 -36
- data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
- data/lib/pwn/ai/agent/request_runtime.rb +122 -18
- data/lib/pwn/ai/agent/reward.rb +5 -3
- data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
- data/lib/pwn/ai/agent/skill_review.rb +9 -5
- data/lib/pwn/ai/agent/swarm.rb +4 -0
- data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
- data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
- data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
- data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/ai/anthropic.rb +8 -33
- data/lib/pwn/ai/cli.rb +34 -5
- data/lib/pwn/ai/gemini.rb +1 -0
- data/lib/pwn/ai/grok.rb +39 -28
- data/lib/pwn/ai/ollama.rb +4 -2
- data/lib/pwn/ai/open_ai.rb +11 -32
- data/lib/pwn/ai/open_web_ui.rb +62 -4
- data/lib/pwn/ai/router.rb +8 -1
- data/lib/pwn/ai/server_tools.rb +231 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/backups.rb +349 -0
- data/lib/pwn/config.rb +70 -39
- data/lib/pwn/migrate.rb +85 -280
- data/lib/pwn/module_skills.rb +7 -0
- data/lib/pwn/plugins/ai_sandbox.rb +538 -1
- data/lib/pwn/plugins/authentication_helper.rb +74 -4
- data/lib/pwn/plugins/basic_auth.rb +2 -1
- data/lib/pwn/plugins/google_workspace.rb +4 -21
- data/lib/pwn/plugins/jobs.rb +1 -1
- data/lib/pwn/plugins/log.rb +3 -1
- data/lib/pwn/plugins/preflight_checker.rb +34 -9
- data/lib/pwn/plugins/recon.rb +4 -1
- data/lib/pwn/plugins/repl/ai.rb +293 -26
- data/lib/pwn/plugins/repl/ai_console.rb +268 -88
- data/lib/pwn/plugins/repl/ai_swarm.rb +177 -58
- data/lib/pwn/plugins/repl/mesh.rb +14 -19
- data/lib/pwn/plugins/repl/vault.rb +4 -15
- data/lib/pwn/plugins/repl.rb +9 -216
- data/lib/pwn/plugins/sandbox/driver.rb +4 -0
- data/lib/pwn/plugins/transparent_browser.rb +215 -0
- data/lib/pwn/plugins/vault.rb +412 -135
- data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
- data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
- data/lib/pwn/plugins/yubikey/management.rb +686 -0
- data/lib/pwn/plugins/yubikey/oath.rb +766 -0
- data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
- data/lib/pwn/plugins/yubikey/otp.rb +897 -0
- data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
- data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
- data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
- data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
- data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
- data/lib/pwn/plugins/yubikey.rb +844 -0
- data/lib/pwn/plugins.rb +1 -0
- data/lib/pwn/redaction.rb +69 -12
- data/lib/pwn/session_trace.rb +4 -0
- data/lib/pwn/setup.rb +114 -24
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn/www/app_cobalt_io.rb +16 -5
- data/lib/pwn/www/bug_crowd.rb +16 -5
- data/lib/pwn/www/coinbase_pro.rb +16 -5
- data/lib/pwn/www/github.rb +30 -14
- data/lib/pwn/www/paypal.rb +15 -4
- data/lib/pwn/www/synack.rb +16 -5
- data/lib/pwn/www/twitter.rb +17 -6
- data/lib/pwn.rb +1 -0
- data/spec/documentation/installation_md_spec.rb +1 -1
- data/spec/fixtures/yubikey/apdu_frames.json +162 -0
- data/spec/integration/authentication_helper_oath_spec.rb +117 -0
- data/spec/integration/controller_spill_spec.rb +48 -0
- data/spec/integration/harness_process_spec.rb +97 -0
- data/spec/integration/mission_cancel_spec.rb +79 -0
- data/spec/integration/mission_curses_pty_spec.rb +44 -0
- data/spec/integration/readme_security_spec.rb +47 -0
- data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
- data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
- data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
- data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
- data/spec/lib/pwn/ai/agent/policy_evaluation_spec.rb +10 -23
- data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
- data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
- data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
- data/spec/lib/pwn/ai/cli_spec.rb +8 -16
- data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
- data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
- data/spec/lib/pwn/ai/router_spec.rb +1 -1
- data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
- data/spec/lib/pwn/backups_spec.rb +129 -0
- data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
- data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
- data/spec/lib/pwn/migrate_spec.rb +29 -81
- data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/module_skills_spec.rb +8 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
- data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
- data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
- data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
- data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
- data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
- data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +110 -75
- data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +19 -16
- data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
- data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
- data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
- data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
- data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
- data/spec/lib/pwn/plugins/repl_mesh_layout_spec.rb +2 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
- data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
- data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
- data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
- data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
- data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
- data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
- data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
- data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
- data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
- data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
- data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
- data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
- data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
- data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
- data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
- data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
- data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
- data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +7 -5
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +11 -11
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +34 -20
- data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
- data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/harness/cli_faults.rb +13 -9
- data/spec/support/harness/controller_spill.rb +26 -0
- data/spec/support/harness/controller_state.rb +54 -0
- data/spec/support/harness/evaluate.rb +12 -1
- data/spec/support/harness/experiments.rb +1 -1
- data/spec/support/harness/fake_provider.rb +22 -6
- data/spec/support/harness/fault_preload.rb +3 -1
- data/spec/support/harness/mission_console.rb +54 -0
- data/spec/support/harness/mission_curses_pty.rb +41 -0
- data/spec/support/harness/mission_dispatch.rb +30 -0
- data/spec/support/harness/mission_fixture_preload.rb +24 -0
- data/spec/support/harness/mission_job_lifetime.rb +25 -0
- data/spec/support/harness/mission_legacy.rb +25 -0
- data/spec/support/harness/mission_normal_exit.rb +34 -0
- data/spec/support/harness/mission_swarm.rb +23 -0
- data/spec/support/harness/mission_unsupported.rb +29 -0
- data/spec/support/harness/package_acceptance.rb +33 -5
- data/spec/support/harness/session_acceptance.rb +4 -4
- data/spec/support/harness/swarm_acceptance.rb +1 -1
- data/spec/support/harness/swarm_control.rb +44 -0
- data/spec/support/mission_namespace.rb +14 -0
- data/spec/support/mission_pipe_queue.rb +33 -0
- data/third_party/pwn_rdoc.jsonl +605 -19
- metadata +129 -16
- data/scripts/benchmark_policy.rb +0 -376
- data/scripts/build_gate.rb +0 -58
- 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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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: '
|
|
98
|
-
refresh_token: '
|
|
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('
|
|
126
|
-
expect(result[:refresh_token]).to eq('
|
|
127
|
-
expect(
|
|
128
|
-
|
|
129
|
-
vault
|
|
130
|
-
expect(
|
|
131
|
-
expect(
|
|
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
|