pwn 0.5.783 → 0.5.785
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 +8 -8
- data/README.md +37 -0
- data/Rakefile +19 -1
- data/bin/pwn +2 -1
- data/bin/pwn_setup +1 -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 +82 -0
- data/documentation/Configuration.md +10 -6
- data/documentation/Contributing.md +1 -1
- data/documentation/Cron.md +64 -35
- data/documentation/Diagrams.md +2 -2
- data/documentation/General-PWN-Usage.md +4 -3
- data/documentation/Home.md +6 -5
- data/documentation/How-PWN-Works.md +7 -6
- data/documentation/Installation.md +254 -10
- data/documentation/Mission-Cancellation.md +134 -0
- data/documentation/Operability.md +17 -0
- data/documentation/Persistence.md +8 -6
- data/documentation/Plugin-Degradation.md +12 -0
- data/documentation/Plugins.md +3 -2
- data/documentation/Reinforcement-Learning.md +5 -6
- 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 +183 -0
- data/documentation/YubiKey.md +339 -0
- data/documentation/diagrams/cron-scheduling.svg +130 -128
- data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
- data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
- data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
- data/documentation/diagrams/persistence-filesystem.svg +172 -142
- data/documentation/diagrams/plugin-ecosystem.svg +64 -64
- data/documentation/pwn-ai-Agent.md +10 -4
- data/etc/default_cron/session_cleanup.rb +1245 -22
- 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/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +62 -25
- 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.rb +5 -0
- data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
- 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 +35 -16
- 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 +53 -3
- data/lib/pwn/ai/server_tools.rb +231 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/backups.rb +347 -0
- data/lib/pwn/config.rb +70 -39
- data/lib/pwn/cron.rb +120 -49
- data/lib/pwn/migrate.rb +150 -68
- 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/binary_parser.rb +5 -4
- data/lib/pwn/plugins/ghidra.rb +4 -3
- data/lib/pwn/plugins/google_workspace.rb +4 -21
- data/lib/pwn/plugins/jobs.rb +1 -1
- data/lib/pwn/plugins/log.rb +21 -3
- data/lib/pwn/plugins/preflight_checker.rb +34 -9
- data/lib/pwn/plugins/recon.rb +4 -1
- data/lib/pwn/plugins/repl/ai.rb +305 -27
- data/lib/pwn/plugins/repl/ai_console.rb +273 -89
- data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
- data/lib/pwn/plugins/repl/mesh.rb +3 -16
- data/lib/pwn/plugins/repl/vault.rb +4 -15
- data/lib/pwn/plugins/repl.rb +1 -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/sessions.rb +58 -16
- data/lib/pwn/setup.rb +123 -41
- 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/scripts/build_gate.rb +58 -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/build_gate_spec.rb +73 -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/persistence_roundtrip_spec.rb +1 -7
- 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_spec.rb +27 -0
- 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 +6 -9
- 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 +31 -3
- data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
- data/spec/lib/pwn/backups_spec.rb +114 -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/cron_spec.rb +113 -6
- data/spec/lib/pwn/migrate_spec.rb +108 -5
- 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_spec.rb +7 -0
- 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 +125 -78
- data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -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_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_spec.rb +13 -10
- data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
- data/spec/lib/pwn/sessions_spec.rb +112 -0
- data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
- data/spec/lib/pwn/setup_spec.rb +3 -1
- 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/gate_failure_formatter.rb +14 -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 +599 -15
- metadata +140 -17
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'stringio'
|
|
6
|
+
|
|
7
|
+
RSpec.describe PWN::Migrate, 'hardware vault migrations' do
|
|
8
|
+
around do |example|
|
|
9
|
+
Dir.mktmpdir('migrate-yubikey-') do |root|
|
|
10
|
+
@root = root
|
|
11
|
+
example.run
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
before do
|
|
16
|
+
allow(Dir).to receive(:home).and_return(@root)
|
|
17
|
+
stub_const('PWN::Migrate::PWN_ROOT', @root)
|
|
18
|
+
stub_const('PWN::Env', { driver_opts: {} })
|
|
19
|
+
stub_const('PWN::Plugins::YubiKey', double('synthetic YubiKey'))
|
|
20
|
+
allow(PWN::Plugins::YubiKey).to receive(:challenge_response) do |opts|
|
|
21
|
+
OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts[:challenge])
|
|
22
|
+
end
|
|
23
|
+
@path = File.join(@root, 'pwn.yaml')
|
|
24
|
+
@original = { ai: { active: 'ollama', ollama: { model: 'fixture-model' } }, retained: 'fixture-value' }
|
|
25
|
+
File.binwrite(@path, PWN::Plugins::Vault.encrypt_data(data: YAML.dump(@original), yubikey: { slot: 2 }))
|
|
26
|
+
@io = StringIO.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'backfills through an atomic encrypted update, preserving the hardware format and user choices' do
|
|
30
|
+
expect(File).not_to receive(:write).with(@path, anything)
|
|
31
|
+
expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
|
|
32
|
+
result = described_class.backfill_vault(io: @io)
|
|
33
|
+
expect(result[:added]).to be_positive
|
|
34
|
+
expect(PWN::Plugins::Vault.hardware_encrypted?(file: @path)).to be(true)
|
|
35
|
+
document = PWN::Plugins::Vault.dump(file: @path)
|
|
36
|
+
expect(document.dig(:ai, :ollama, :model)).to eq('fixture-model')
|
|
37
|
+
expect(document[:retained]).to eq('fixture-value')
|
|
38
|
+
expect(document.dig(:ai, :agent)).to eq(PWN::Config.env_template.dig(:ai, :agent))
|
|
39
|
+
bytes = File.binread(@path)
|
|
40
|
+
expect(described_class.backfill_vault(io: @io)[:added]).to eq(0)
|
|
41
|
+
expect(File.binread(@path)).to eq(bytes)
|
|
42
|
+
expect(Dir.children(@root)).to eq(['pwn.yaml'])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'uses driver-selected hardware vault paths without touching the canonical vault' do
|
|
46
|
+
custom = File.join(@root, 'selected-vault')
|
|
47
|
+
FileUtils.cp(@path, custom)
|
|
48
|
+
PWN::Env[:driver_opts] = { pwn_env_path: custom, pwn_dec_path: File.join(@root, 'absent-sidecar') }
|
|
49
|
+
canonical = File.binread(@path)
|
|
50
|
+
expect(described_class.backfill_vault(io: @io)[:added]).to be_positive
|
|
51
|
+
expect(described_class.vault_drift[:missing]).to be_empty
|
|
52
|
+
expect(File.binread(@path)).to eq(canonical)
|
|
53
|
+
expect(PWN::Plugins::Vault.hardware_encrypted?(file: custom)).to be(true)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'honors an explicit independent legacy decryptor and vault over driver defaults' do
|
|
57
|
+
custom = File.join(@root, 'legacy-vault')
|
|
58
|
+
sidecar = File.join(@root, 'separate-legacy-decryptor')
|
|
59
|
+
File.write(custom, YAML.dump(@original))
|
|
60
|
+
PWN::Plugins::Vault.create(file: custom, decryptor_file: sidecar)
|
|
61
|
+
canonical = File.binread(@path)
|
|
62
|
+
credentials = YAML.safe_load_file(sidecar, symbolize_names: true)
|
|
63
|
+
result = described_class.backfill_vault(pwn_env_path: custom, pwn_dec_path: sidecar, io: @io)
|
|
64
|
+
expect(result[:added]).to be_positive
|
|
65
|
+
expect(PWN::Plugins::Vault.dump(credentials.merge(file: custom)).dig(:ai, :agent)).to be_a(Hash)
|
|
66
|
+
expect(File.binread(@path)).to eq(canonical)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'forwards explicit vault selection and hardware overrides through numbered migrations and fix runs' do
|
|
70
|
+
custom = File.join(@root, 'selected-vault')
|
|
71
|
+
FileUtils.cp(@path, custom)
|
|
72
|
+
canonical = File.binread(@path)
|
|
73
|
+
allow(PWN::Backups).to receive(:active?).and_return(true)
|
|
74
|
+
allow(described_class).to receive(:status).and_return(schema_installed: 3, schema_current: 4, pwn_version: 'fixture', incompatible: [])
|
|
75
|
+
allow(described_class).to receive(:stamp!)
|
|
76
|
+
stub_const('PWN::Migrate::SCHEMA_VERSION', 4)
|
|
77
|
+
allow(PWN::Plugins::YubiKey).to receive(:challenge_response) do |opts|
|
|
78
|
+
raise IOError, 'selector was not forwarded' unless opts[:device_path] == '/fixture/native-device'
|
|
79
|
+
|
|
80
|
+
OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts[:challenge])
|
|
81
|
+
end
|
|
82
|
+
[false, true].each do |fix|
|
|
83
|
+
File.binwrite(custom, canonical)
|
|
84
|
+
allow(described_class).to receive(:status).and_return(schema_installed: fix ? 4 : 3, schema_current: 4, pwn_version: 'fixture', incompatible: [])
|
|
85
|
+
result = described_class.run(fix: fix, backup: false, io: @io, pwn_env_path: custom, yubikey: { device_path: '/fixture/native-device' })
|
|
86
|
+
expect(result[:applied_migrations]).to eq(fix ? [] : [4])
|
|
87
|
+
document = PWN::Plugins::Vault.dump(file: custom, yubikey: { device_path: '/fixture/native-device' })
|
|
88
|
+
expect(document.dig(:ai, :agent)).to be_a(Hash)
|
|
89
|
+
expect(File.binread(@path) == canonical).to be(true)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'reports the explicitly selected vault rather than a missing canonical file during migration' do
|
|
94
|
+
custom = File.join(@root, 'selected-vault')
|
|
95
|
+
File.rename(@path, custom)
|
|
96
|
+
stub_const('PWN::Migrate::SCHEMA_FILE', File.join(@root, '.schema'))
|
|
97
|
+
File.write(described_class::SCHEMA_FILE, JSON.generate(schema: described_class::SCHEMA_VERSION))
|
|
98
|
+
allow(PWN::Backups).to receive(:active?).and_return(true)
|
|
99
|
+
result = described_class.run(fix: true, backup: false, io: @io, pwn_env_path: custom)
|
|
100
|
+
expect(result[:before][:incompatible]).to be_empty
|
|
101
|
+
expect(result[:after][:incompatible]).to be_empty
|
|
102
|
+
expect(result[:after][:files].find { |row| row[:rel] == 'pwn.yaml' }[:path]).to eq(custom)
|
|
103
|
+
expect(File).not_to exist(@path)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it 'does not write during a dry run or when hardware encryption fails or is interrupted' do
|
|
107
|
+
bytes = File.binread(@path)
|
|
108
|
+
expect(described_class.backfill_vault(dry_run: true, io: @io)[:added]).to be_positive
|
|
109
|
+
expect(File.binread(@path)).to eq(bytes)
|
|
110
|
+
allow(PWN::Plugins::Vault).to receive(:encrypt_data).and_raise(IOError, 'synthetic device unavailable')
|
|
111
|
+
expect(described_class.backfill_vault(io: @io)).to be_nil
|
|
112
|
+
expect(File.binread(@path)).to eq(bytes)
|
|
113
|
+
allow(PWN::Plugins::Vault).to receive(:encrypt_data).and_raise(Interrupt)
|
|
114
|
+
expect { described_class.backfill_vault(io: @io) }.to raise_error(Interrupt)
|
|
115
|
+
expect(File.binread(@path)).to eq(bytes)
|
|
116
|
+
expect(Dir.children(@root)).to eq(['pwn.yaml'])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'reports missing keys in a hardware vault without a software decryptor' do
|
|
120
|
+
bytes = File.binread(@path)
|
|
121
|
+
expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
|
|
122
|
+
result = described_class.vault_drift
|
|
123
|
+
expect(result).to include(user: @original)
|
|
124
|
+
expect(result[:missing]).to include('ai.agent')
|
|
125
|
+
expect(File.binread(@path)).to eq(bytes)
|
|
126
|
+
expect(File).not_to exist("#{@path}.decryptor")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -51,6 +51,14 @@ describe PWN::ModuleSkills do
|
|
|
51
51
|
expect(described_class).to respond_to :help
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
it 'identifies the file-owning class rather than its enclosing module or nested error class' do
|
|
55
|
+
lib = File.expand_path('../../../lib', __dir__)
|
|
56
|
+
rec = described_class.send(:parse_file, path: File.join(lib, 'pwn/ai/agent/mission_namespace.rb'), lib_root: lib).first
|
|
57
|
+
expect(rec[:const]).to eq('PWN::AI::Agent::MissionNamespace')
|
|
58
|
+
expect(rec[:source]).to eq('pwn/ai/agent/mission_namespace.rb')
|
|
59
|
+
expect(described_class.render(module: rec)).to include('module: PWN::AI::Agent::MissionNamespace')
|
|
60
|
+
end
|
|
61
|
+
|
|
54
62
|
it 'maps a constant or source file to a namespace-mirrored SKILL.md path' do
|
|
55
63
|
expect(described_class.relpath(const: 'PWN::Plugins::TransparentBrowser')).to eq(
|
|
56
64
|
'pwn/plugins/transparent_browser/SKILL.md'
|
|
@@ -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
|
|
|
@@ -15,6 +15,13 @@ describe PWN::Plugins::Log do
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
describe 'pwn-ai debug session' do
|
|
18
|
+
include_context 'pwn tmp sandbox'
|
|
19
|
+
|
|
20
|
+
before do
|
|
21
|
+
allow(Dir).to receive(:home).and_return(File.join(@tmp, 'home'))
|
|
22
|
+
FileUtils.mkdir_p(File.join(Dir.home, '.pwn'))
|
|
23
|
+
end
|
|
24
|
+
|
|
18
25
|
after do
|
|
19
26
|
PWN::Plugins::Log.stop_debug if PWN::Plugins::Log.respond_to?(:stop_debug)
|
|
20
27
|
end
|