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
|
@@ -5,17 +5,31 @@ require 'open3'
|
|
|
5
5
|
require 'timeout'
|
|
6
6
|
require 'fileutils'
|
|
7
7
|
require 'stringio'
|
|
8
|
+
require 'ffi'
|
|
9
|
+
require 'find'
|
|
10
|
+
require 'tmpdir'
|
|
11
|
+
require 'socket'
|
|
8
12
|
|
|
9
13
|
module PWN
|
|
10
14
|
module Plugins
|
|
11
15
|
# Forked worker for model-authored shell/Ruby with Landlock/seccomp profiles.
|
|
12
16
|
module AISandbox
|
|
17
|
+
ALIAS_MUTEX = Mutex.new
|
|
18
|
+
ALIAS_CACHE = {} # rubocop:disable Style/MutableConstant -- host-only inode fingerprint cache
|
|
19
|
+
|
|
20
|
+
NotStarted = PWN::AI::Agent::MissionNamespace::Unavailable
|
|
21
|
+
|
|
22
|
+
# One kernel-owned process tree. Namespace PID 1 owns every descendant,
|
|
23
|
+
# including double forks and setsid jobs. Reaping PID 1 completes teardown.
|
|
24
|
+
# No process-group fallback: unsupported hosts never execute the block.
|
|
25
|
+
MissionProcess = PWN::AI::Agent::MissionNamespace
|
|
26
|
+
|
|
13
27
|
WRITE_RX = %r{\b(?:rm|rmdir|unlink|shred|truncate)\b|FileUtils\.rm|File\.(?:delete|unlink)|Pathname.*rmtree|>\s*/}
|
|
14
28
|
NET_RX = /\b(?:curl|wget|nc|ncat|ssh|scp|ftp|telnet)\b|TCPSocket|UDPSocket|Net::HTTP|Socket\.(?:tcp|udp)/
|
|
15
29
|
PATH_RX = %r{FileUtils\.\w+\(\s*['"]([^'"]+)['"]|(?:^|\s)(?:rm|rmdir)\s+(?:-[a-zA-Z]+\s+)*([^\s;|&]+)|['"](/(?:[^'"]+))['"]}
|
|
16
30
|
|
|
17
31
|
public_class_method def self.required_bins
|
|
18
|
-
[]
|
|
32
|
+
%w[unshare]
|
|
19
33
|
end
|
|
20
34
|
|
|
21
35
|
# Read the ai_sandbox key as strict, permissive, or off.
|
|
@@ -61,12 +75,535 @@ module PWN
|
|
|
61
75
|
exec(opts.merge(kind: :ruby, payload: opts[:code] || opts[:payload]))
|
|
62
76
|
end
|
|
63
77
|
|
|
78
|
+
# Pin each controller directory component without following symlinks.
|
|
79
|
+
# Callers must use NOFOLLOW/EXCL for final files within the yielded fd path.
|
|
80
|
+
public_class_method def self.control_directory(opts = {})
|
|
81
|
+
raise NotStarted, 'safe controller persistence requires Linux openat' unless RUBY_PLATFORM.include?('linux')
|
|
82
|
+
|
|
83
|
+
native = Module.new do
|
|
84
|
+
extend ::FFI::Library
|
|
85
|
+
|
|
86
|
+
ffi_lib ::FFI::Library::LIBC
|
|
87
|
+
attach_function :openat, %i[int string int uint], :int
|
|
88
|
+
attach_function :mkdirat, %i[int string uint], :int
|
|
89
|
+
end
|
|
90
|
+
flags = File::RDONLY | File::NOFOLLOW | 0x10000 | 0x80000 # O_DIRECTORY | O_CLOEXEC
|
|
91
|
+
identities = protected_roots.map do |path|
|
|
92
|
+
stat = File.stat(path)
|
|
93
|
+
[stat.dev, stat.ino]
|
|
94
|
+
end
|
|
95
|
+
directory = IO.new(IO.sysopen('/', flags))
|
|
96
|
+
File.expand_path(opts.fetch(:path)).split('/').reject(&:empty?).each do |part|
|
|
97
|
+
fd = native.openat(directory.fileno, part, flags, 0)
|
|
98
|
+
if fd.negative? && ::FFI.errno == Errno::ENOENT::Errno
|
|
99
|
+
result = native.mkdirat(directory.fileno, part, 0o700)
|
|
100
|
+
raise SystemCallError.new('controller mkdirat', ::FFI.errno) if result.negative? && ::FFI.errno != Errno::EEXIST::Errno
|
|
101
|
+
|
|
102
|
+
fd = native.openat(directory.fileno, part, flags, 0)
|
|
103
|
+
end
|
|
104
|
+
raise SystemCallError.new('controller openat (symlink ancestry is forbidden)', ::FFI.errno) if fd.negative?
|
|
105
|
+
|
|
106
|
+
child = IO.new(fd)
|
|
107
|
+
directory.close
|
|
108
|
+
directory = child
|
|
109
|
+
stat = directory.stat
|
|
110
|
+
raise NotStarted, 'controller persistence path enters protected source' if identities.include?([stat.dev, stat.ino])
|
|
111
|
+
end
|
|
112
|
+
yield "/proc/self/fd/#{directory.fileno}"
|
|
113
|
+
ensure
|
|
114
|
+
directory&.close unless directory&.closed?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Mandatory repository boundary, independent of the optional profile.
|
|
118
|
+
# The block runs only in a child; cwd and restrictions never affect the UI.
|
|
119
|
+
public_class_method def self.protect_call(opts = {})
|
|
120
|
+
return yield if Thread.current[:pwn_repository_worker]
|
|
121
|
+
return protect_descriptor(opts) unless PWN::AI::Agent::MissionNamespace.owned || (PWN::AI::Agent::MissionNamespace.prepared? && !PWN::AI::Agent::MissionNamespace.force_exec)
|
|
122
|
+
|
|
123
|
+
roots = Array(opts[:roots] || protected_roots).map { |path| File.realpath(path) }.uniq
|
|
124
|
+
scratch = workspace
|
|
125
|
+
FileUtils.mkdir_p(scratch, mode: 0o700)
|
|
126
|
+
scratch = File.realpath(scratch)
|
|
127
|
+
task_dir = File.expand_path(opts[:workspace] || scratch)
|
|
128
|
+
ancestor = task_dir
|
|
129
|
+
ancestor = File.dirname(ancestor) until File.exist?(ancestor)
|
|
130
|
+
resolved = File.join(File.realpath(ancestor), task_dir.delete_prefix(ancestor).delete_prefix('/'))
|
|
131
|
+
raise SecurityError, 'runtime workspace is inside protected source' if roots.any? { |root| resolved == root || resolved.start_with?("#{root}/") }
|
|
132
|
+
|
|
133
|
+
FileUtils.mkdir_p(task_dir, mode: 0o700)
|
|
134
|
+
task_dir = File.realpath(task_dir)
|
|
135
|
+
if opts[:runtime]
|
|
136
|
+
provision_swarm_state
|
|
137
|
+
controller_root = File.dirname(opts[:runtime].checkpoint_path, 2)
|
|
138
|
+
roots += [File.realpath(controller_root), File.realpath(PWN::AI::Agent::Swarm::SWARM_ROOT), File.realpath(PWN::AI::Agent::Swarm::AGENTS_FILE)]
|
|
139
|
+
end
|
|
140
|
+
roots.concat(controller_state_roots)
|
|
141
|
+
explicit = prepare_write_roots(paths: opts[:write_roots])
|
|
142
|
+
writable = [scratch, '/dev/null', '/dev/zero', '/dev/random', '/dev/urandom'] + explicit
|
|
143
|
+
writable += prepare_write_roots(paths: state_write_roots) unless opts.dig(:handler, :name).to_s == 'shell'
|
|
144
|
+
writable = writable.filter_map { |path| File.realpath(path) if File.exist?(path) && !File.symlink?(path) }.uniq
|
|
145
|
+
writable.reject! { |path| %w[/ /proc /sys /dev].include?(path) }
|
|
146
|
+
aliases, identities = repository_aliases(roots: roots, writable: writable)
|
|
147
|
+
|
|
148
|
+
reader, writer = IO.pipe
|
|
149
|
+
controller, client = UNIXSocket.pair
|
|
150
|
+
mission = nil
|
|
151
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
152
|
+
mission = MissionProcess.new(inherit_namespace: true) do
|
|
153
|
+
reader.close
|
|
154
|
+
controller.close
|
|
155
|
+
Thread.current[:pwn_swarm_controller] = client if opts[:runtime]
|
|
156
|
+
Signal.trap('TERM') { raise Interrupt, 'runtime worker cancelled' }
|
|
157
|
+
begin
|
|
158
|
+
repository_restrict!(roots: roots + aliases, writable: writable, explicit: explicit)
|
|
159
|
+
repository_metadata_restrict!(roots: roots + aliases, identities: identities)
|
|
160
|
+
Dir.chdir(task_dir)
|
|
161
|
+
ENV['TMPDIR'] = scratch
|
|
162
|
+
Thread.current[:pwn_repository_worker] = true
|
|
163
|
+
opts[:runtime]&.worker_view!
|
|
164
|
+
started = true
|
|
165
|
+
result = yield
|
|
166
|
+
provenance = opts[:runtime]&.observations&.transform_values { |row| row.except(:head, :tail) } || {}
|
|
167
|
+
writer.write(JSON.generate(started: true, provenance: provenance, result: PWN::AI::Agent::Result.utf8_value(value: result)))
|
|
168
|
+
rescue StandardError, SecurityError, NotImplementedError => e
|
|
169
|
+
writer.write(JSON.generate(started: started == true, result: { success: false, error: "repository protection: #{e.class}: #{e.message}" }))
|
|
170
|
+
ensure
|
|
171
|
+
writer.close
|
|
172
|
+
exit!
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
writer.close
|
|
177
|
+
client.close
|
|
178
|
+
raw = collect_worker(reader: reader, controller: controller, runtime: opts[:runtime])
|
|
179
|
+
mission.wait
|
|
180
|
+
envelope = JSON.parse(raw, symbolize_names: true)
|
|
181
|
+
raise NotStarted, envelope.dig(:result, :error) unless envelope[:started]
|
|
182
|
+
|
|
183
|
+
opts[:runtime]&.worker_provenance!(rows: (envelope[:provenance] || {}).transform_keys(&:to_s).merge(opts[:runtime].observations))
|
|
184
|
+
envelope[:result]
|
|
185
|
+
rescue NotImplementedError, SecurityError, SystemCallError => e
|
|
186
|
+
raise NotStarted, "repository protection unavailable: #{e.message}"
|
|
187
|
+
ensure
|
|
188
|
+
reader&.close unless reader&.closed?
|
|
189
|
+
writer&.close unless writer&.closed?
|
|
190
|
+
mission&.close
|
|
191
|
+
controller&.close unless controller&.closed?
|
|
192
|
+
client&.close unless client&.closed?
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private_class_method def self.protect_descriptor(opts = {})
|
|
196
|
+
raise NotStarted, 'protected host calls require a serializable handler descriptor' unless opts[:handler]
|
|
197
|
+
|
|
198
|
+
reader, writer = IO.pipe
|
|
199
|
+
controller, client = UNIXSocket.pair
|
|
200
|
+
descriptor = { operation: :protected, options: opts.except(:runtime), runtime: opts[:runtime]&.mission_snapshot }
|
|
201
|
+
mission = MissionProcess.new(descriptor: descriptor, fds: { 4 => writer, 5 => client })
|
|
202
|
+
writer.close
|
|
203
|
+
client.close
|
|
204
|
+
raw = collect_worker(reader: reader, controller: controller, runtime: opts[:runtime])
|
|
205
|
+
mission.wait
|
|
206
|
+
envelope = JSON.parse(raw, symbolize_names: true)
|
|
207
|
+
raise NotStarted, envelope[:error] if envelope[:error]
|
|
208
|
+
|
|
209
|
+
opts[:runtime]&.worker_provenance!(rows: envelope.fetch(:provenance, {}).transform_keys(&:to_s))
|
|
210
|
+
envelope[:result]
|
|
211
|
+
ensure
|
|
212
|
+
mission&.close
|
|
213
|
+
[reader, writer, controller, client].compact.each { |io| io.close unless io.closed? }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private_class_method def self.mission_entry(opts = {})
|
|
217
|
+
writer = PWN::AI::Agent::MissionNamespace.io(4)
|
|
218
|
+
Thread.current[:pwn_swarm_controller] = PWN::AI::Agent::MissionNamespace.io(5)
|
|
219
|
+
runtime = PWN::AI::Agent::RequestRuntime.send(:from_mission_snapshot, state: opts[:runtime]) if opts[:runtime]
|
|
220
|
+
Thread.current[:pwn_request_runtime] = runtime
|
|
221
|
+
options = opts.fetch(:options)
|
|
222
|
+
handler = options.fetch(:handler)
|
|
223
|
+
PWN::AI::Agent::Registry.discover
|
|
224
|
+
begin
|
|
225
|
+
result = protect_call(options.merge(runtime: runtime)) do
|
|
226
|
+
PWN::AI::Agent::Registry.lookup(name: handler.fetch(:name)).handler.call(handler.fetch(:args))
|
|
227
|
+
end
|
|
228
|
+
writer.write(JSON.generate(result: result, provenance: runtime&.observations || {}))
|
|
229
|
+
rescue StandardError => e
|
|
230
|
+
writer.write(JSON.generate(error: "#{e.class}: #{e.message}"))
|
|
231
|
+
ensure
|
|
232
|
+
writer.close
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Typed swarm control operations; never execute a registry handler on the host.
|
|
237
|
+
public_class_method def self.swarm_control(opts = {})
|
|
238
|
+
socket = Thread.current[:pwn_swarm_controller]
|
|
239
|
+
raise NotStarted, 'swarm controller unavailable' unless socket
|
|
240
|
+
|
|
241
|
+
socket.puts(JSON.generate(operation: opts.fetch(:operation), args: opts.fetch(:args)))
|
|
242
|
+
reply = JSON.parse(socket.gets || '{}', symbolize_names: true)
|
|
243
|
+
raise NotStarted, reply[:error] if reply[:error]
|
|
244
|
+
|
|
245
|
+
reply.fetch(:result)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private_class_method def self.provision_swarm_state
|
|
249
|
+
swarm = PWN::AI::Agent::Swarm
|
|
250
|
+
control_directory(path: swarm::SWARM_ROOT) { |_directory| nil }
|
|
251
|
+
control_directory(path: File.dirname(swarm::AGENTS_FILE)) do |directory|
|
|
252
|
+
[File.basename(swarm::AGENTS_FILE), '.swarm-continuations.lock'].each do |name|
|
|
253
|
+
File.open(File.join(directory, name), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |file|
|
|
254
|
+
raise NotStarted, 'swarm state must be a singly linked regular file' unless file.stat.file? && file.stat.nlink == 1
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
private_class_method def self.collect_worker(opts = {})
|
|
261
|
+
reader = opts.fetch(:reader)
|
|
262
|
+
controller = opts.fetch(:controller)
|
|
263
|
+
streams = [reader, controller]
|
|
264
|
+
bytes = +''
|
|
265
|
+
loop do
|
|
266
|
+
IO.select(streams).first.each do |stream|
|
|
267
|
+
if stream == reader
|
|
268
|
+
chunk = reader.read_nonblock(65_536, exception: false)
|
|
269
|
+
return bytes if chunk.nil?
|
|
270
|
+
|
|
271
|
+
bytes << chunk unless chunk == :wait_readable
|
|
272
|
+
else
|
|
273
|
+
line = controller.gets(65_537)
|
|
274
|
+
if line.nil?
|
|
275
|
+
streams.delete(controller)
|
|
276
|
+
next
|
|
277
|
+
end
|
|
278
|
+
reply = swarm_control_reply(line: line, runtime: opts[:runtime])
|
|
279
|
+
controller.puts(JSON.generate(reply))
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
private_class_method def self.swarm_control_reply(opts = {})
|
|
286
|
+
raise NotStarted, 'swarm controller requires a request runtime' unless opts[:runtime]
|
|
287
|
+
raise ArgumentError, 'swarm control frame too large' if opts.fetch(:line).bytesize > 65_536
|
|
288
|
+
|
|
289
|
+
frame = JSON.parse(opts.fetch(:line), symbolize_names: true)
|
|
290
|
+
operation = frame.fetch(:operation)
|
|
291
|
+
opts[:runtime].check_cancelled!
|
|
292
|
+
raise ArgumentError, 'swarm control permission denied' unless opts[:runtime].tool_allowed?(name: "agent_#{operation}", toolset: 'swarm')
|
|
293
|
+
|
|
294
|
+
fields = case operation
|
|
295
|
+
when 'spawn' then %i[name role toolsets engine model max_iters]
|
|
296
|
+
when 'ask' then %i[name request swarm_id unit ttl engagement_id]
|
|
297
|
+
else raise ArgumentError, 'unsupported swarm control operation'
|
|
298
|
+
end
|
|
299
|
+
args = frame.fetch(:args)
|
|
300
|
+
raise ArgumentError, 'unsupported swarm control fields' unless args.is_a?(Hash) && (args.keys - fields).empty?
|
|
301
|
+
|
|
302
|
+
%i[swarm_id engagement_id].each do |key|
|
|
303
|
+
next if args[key].nil?
|
|
304
|
+
raise ArgumentError, 'invalid swarm control identifier' unless args[key].is_a?(String) && args[key].match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
305
|
+
end
|
|
306
|
+
swarm = PWN::AI::Agent::Swarm
|
|
307
|
+
control_directory(path: swarm::SWARM_ROOT) do |_directory|
|
|
308
|
+
control_directory(path: File.join(swarm::SWARM_ROOT, args[:swarm_id])) { |_child| nil } if args[:swarm_id]
|
|
309
|
+
end
|
|
310
|
+
result = operation == 'spawn' ? swarm.spawn(args) : swarm.ask(args)
|
|
311
|
+
{ result: result }
|
|
312
|
+
rescue StandardError => e
|
|
313
|
+
{ error: "swarm controller: #{e.class}: #{e.message}" }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Default new-file directory. Process cwd is not an implicit write grant.
|
|
317
|
+
public_class_method def self.workspace
|
|
318
|
+
File.expand_path(File.join(Dir.home, '.pwn', 'workspaces', 'runtime'))
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Relative reads use a safe task directory. Protected-source launches use
|
|
322
|
+
# the runtime workspace instead.
|
|
323
|
+
public_class_method def self.task_directory
|
|
324
|
+
cwd = File.realpath(Dir.pwd)
|
|
325
|
+
protected = protected_roots.any? { |root| cwd == root || cwd.start_with?("#{root}/") || root.start_with?("#{cwd}/") }
|
|
326
|
+
return workspace if cwd == '/' || protected
|
|
327
|
+
|
|
328
|
+
cwd
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Materialize operator-named destinations. A file literal grants its parent
|
|
332
|
+
# so creation works; a directory literal grants itself. Protected source
|
|
333
|
+
# and symlinks are never granted.
|
|
334
|
+
public_class_method def self.prepare_write_roots(opts = {})
|
|
335
|
+
roots = protected_roots
|
|
336
|
+
Array(opts[:paths]).filter_map do |literal|
|
|
337
|
+
path = File.expand_path(literal.to_s)
|
|
338
|
+
next if protected_path?(path: path, roots: roots)
|
|
339
|
+
|
|
340
|
+
# An existing directory is the grant. A file, or a destination that does
|
|
341
|
+
# not exist yet, grants its parent so mv/touch can create the file.
|
|
342
|
+
# Do not mkdir a missing extensionless path; that would turn
|
|
343
|
+
# `mv foo /tmp/test` into a move into a directory.
|
|
344
|
+
grant = File.directory?(path) && !File.symlink?(path) ? path : File.dirname(path)
|
|
345
|
+
next if protected_path?(path: grant, roots: roots) || %w[/ /proc /sys /dev].include?(grant)
|
|
346
|
+
next if File.symlink?(grant)
|
|
347
|
+
|
|
348
|
+
FileUtils.mkdir_p(grant, mode: 0o700) unless File.directory?(grant)
|
|
349
|
+
next unless File.directory?(grant) && !File.symlink?(grant)
|
|
350
|
+
|
|
351
|
+
resolved = File.realpath(grant)
|
|
352
|
+
next if protected_path?(path: resolved, roots: roots)
|
|
353
|
+
|
|
354
|
+
resolved
|
|
355
|
+
end.uniq
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Detect both the conventional checkout and the loaded package source.
|
|
359
|
+
public_class_method def self.protected_roots
|
|
360
|
+
['/opt/pwn', File.expand_path('../../..', __dir__)].select { |path| File.directory?(path) }.map { |path| File.realpath(path) }.uniq
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
private_class_method def self.protected_path?(opts = {})
|
|
364
|
+
path = opts[:path]
|
|
365
|
+
opts.fetch(:roots).any? { |root| path == root || path.start_with?("#{root}/") }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Plugin records, not the default scratch tree. Shell does not receive these.
|
|
369
|
+
private_class_method def self.state_write_roots
|
|
370
|
+
denied = controller_state_roots
|
|
371
|
+
%w[jobs cache ghidra redb binws wordlists exports sandbox_snapshots intel].filter_map do |name|
|
|
372
|
+
path = File.join(Dir.home, '.pwn', name)
|
|
373
|
+
next if protected_path?(path: path, roots: denied)
|
|
374
|
+
|
|
375
|
+
path
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Keep controller persistence out of tool grants, including absent paths.
|
|
380
|
+
# A missing deny still splits its ancestor grant, so tools cannot plant it
|
|
381
|
+
# before the controller's first append. Honour test/custom store constants.
|
|
382
|
+
private_class_method def self.controller_state_roots
|
|
383
|
+
agent = PWN::AI::Agent
|
|
384
|
+
stores = [PWN::Sessions, PWN::Memory, PWN::MemoryIndex, PWN::Plugins::ArtifactRegistry,
|
|
385
|
+
agent::Learning, agent::Policy, agent::Metrics, agent::Reward, agent::Mistakes,
|
|
386
|
+
agent::OpenGoal, agent::Mission, agent::TaskDAG, agent::SkillReview, agent::Engagement,
|
|
387
|
+
agent::Curriculum, agent::ToolGuard, agent::Extrospection, agent::Swarm]
|
|
388
|
+
paths = stores.flat_map do |store|
|
|
389
|
+
store.constants(false).filter_map do |name|
|
|
390
|
+
next unless name.to_s.match?(/(?:_FILE|_DIR|_ROOT|LEDGER|ROOT)\z/)
|
|
391
|
+
|
|
392
|
+
value = store.const_get(name, false)
|
|
393
|
+
value if value.is_a?(String) && value.start_with?('/')
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
paths.concat(%w[artifacts logs learning metrics skills .swarm-continuations.lock pwn.yaml pwn.yaml.decryptor vault-secrets.json].map { |name| File.join(Dir.home, '.pwn', name) })
|
|
397
|
+
paths << File.join(Dir.home, '.pwn-vault.key')
|
|
398
|
+
if defined?(PWN::Env) && PWN::Env.is_a?(Hash)
|
|
399
|
+
%i[pwn_env_path pwn_dec_path pwn_skills_path].each do |key|
|
|
400
|
+
path = PWN::Env.dig(:driver_opts, key)
|
|
401
|
+
paths << path unless path.to_s.empty?
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
paths.concat(PWN::Skills.values.filter_map { |meta| File.dirname(meta[:path]) if meta.is_a?(Hash) && meta[:path].is_a?(String) }) if defined?(PWN::Skills) && PWN::Skills.is_a?(Hash)
|
|
405
|
+
paths.flat_map do |path|
|
|
406
|
+
path = File.expand_path(path)
|
|
407
|
+
ancestor = path
|
|
408
|
+
ancestor = File.dirname(ancestor) until File.exist?(ancestor) || File.symlink?(ancestor)
|
|
409
|
+
# Preserve the lexical deny (including absent paths), and also deny
|
|
410
|
+
# the canonical store behind an operator-configured symlink.
|
|
411
|
+
[path, "#{File.realpath(ancestor)}#{path.delete_prefix(ancestor)}"]
|
|
412
|
+
end.uniq
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
private_class_method def self.repository_aliases(opts = {})
|
|
416
|
+
identities = {}
|
|
417
|
+
opts.fetch(:roots).each do |root|
|
|
418
|
+
next unless File.exist?(root)
|
|
419
|
+
|
|
420
|
+
Find.find(root) do |path|
|
|
421
|
+
stat = File.lstat(path)
|
|
422
|
+
identities[[stat.dev, stat.ino]] = [stat.nlink, stat.ctime.to_r] if stat.file? && stat.nlink > 1
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
return [[], []] if identities.empty?
|
|
426
|
+
|
|
427
|
+
aliases = opts.fetch(:writable).flat_map do |root|
|
|
428
|
+
stat = File.stat(root)
|
|
429
|
+
key = [identities, root, stat.dev, stat.ino]
|
|
430
|
+
ALIAS_MUTEX.synchronize do
|
|
431
|
+
ALIAS_CACHE.shift if ALIAS_CACHE.length >= 64
|
|
432
|
+
ALIAS_CACHE[key] ||= repository_scan_aliases(root: root, identities: identities).freeze
|
|
433
|
+
end
|
|
434
|
+
end.uniq
|
|
435
|
+
[aliases, identities.keys]
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
private_class_method def self.repository_scan_aliases(opts = {})
|
|
439
|
+
aliases = []
|
|
440
|
+
Find.find(opts.fetch(:root)) do |path|
|
|
441
|
+
stat = File.lstat(path)
|
|
442
|
+
if stat.directory? && (!File.readable?(path) || !File.executable?(path))
|
|
443
|
+
aliases << path
|
|
444
|
+
Find.prune
|
|
445
|
+
end
|
|
446
|
+
aliases << path if stat.file? && opts.fetch(:identities)[[stat.dev, stat.ino]]
|
|
447
|
+
rescue Errno::ENOENT
|
|
448
|
+
next
|
|
449
|
+
end
|
|
450
|
+
aliases
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
private_class_method def self.repository_metadata_restrict!(opts = {})
|
|
454
|
+
roots = opts.fetch(:roots)
|
|
455
|
+
# Landlock does not mediate chmod/chown/xattr/time metadata. Deny those
|
|
456
|
+
# syscalls conservatively, also outside source, rather than claim a
|
|
457
|
+
# path regex makes arbitrary Ruby safe. Content writes remain usable.
|
|
458
|
+
native = Module.new do
|
|
459
|
+
extend ::FFI::Library
|
|
460
|
+
|
|
461
|
+
ffi_lib 'libseccomp.so.2'
|
|
462
|
+
attach_function :seccomp_init, [:uint32], :pointer
|
|
463
|
+
attach_function :seccomp_syscall_resolve_name, [:string], :int
|
|
464
|
+
attach_function :seccomp_rule_add, %i[pointer uint32 int uint], :int
|
|
465
|
+
attach_function :seccomp_load, [:pointer], :int
|
|
466
|
+
attach_function :seccomp_release, [:pointer], :void
|
|
467
|
+
end
|
|
468
|
+
context = native.seccomp_init(0x7fff0000)
|
|
469
|
+
raise SecurityError, 'seccomp initialization failed' if context.null?
|
|
470
|
+
|
|
471
|
+
begin
|
|
472
|
+
%w[chmod fchmod fchmodat fchmodat2 chown fchown lchown fchownat
|
|
473
|
+
utime utimes futimesat utimensat setxattr lsetxattr fsetxattr
|
|
474
|
+
removexattr lremovexattr fremovexattr mount umount2 pivot_root
|
|
475
|
+
move_mount open_tree fsopen fsconfig fsmount mount_setattr unshare
|
|
476
|
+
setns ptrace process_vm_writev io_uring_setup].each do |name|
|
|
477
|
+
number = native.seccomp_syscall_resolve_name(name)
|
|
478
|
+
next if number.negative?
|
|
479
|
+
raise SecurityError, "seccomp rule failed: #{name}" unless native.seccomp_rule_add(context, 0x50000 | 1, number, 0).zero?
|
|
480
|
+
end
|
|
481
|
+
raise SecurityError, 'seccomp load failed' unless native.seccomp_load(context).zero?
|
|
482
|
+
ensure
|
|
483
|
+
native.seccomp_release(context)
|
|
484
|
+
end
|
|
485
|
+
# Close descriptors opened before Landlock; its policy applies to open,
|
|
486
|
+
# not writes through an already-open file description.
|
|
487
|
+
Dir.children('/proc/self/fd').each do |number|
|
|
488
|
+
fd = number.to_i
|
|
489
|
+
begin
|
|
490
|
+
path = File.realpath("/proc/self/fd/#{fd}")
|
|
491
|
+
stat = File.stat("/proc/self/fd/#{fd}")
|
|
492
|
+
IO.new(fd).close if opts.fetch(:identities).include?([stat.dev, stat.ino]) || roots.any? { |root| path == root || path.start_with?("#{root}/") }
|
|
493
|
+
rescue Errno::ENOENT, Errno::EBADF
|
|
494
|
+
nil
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
:restricted
|
|
498
|
+
rescue LoadError => e
|
|
499
|
+
raise SecurityError, "libseccomp is required: #{e.message}"
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
private_class_method def self.repository_restrict!(opts = {})
|
|
503
|
+
raise NotImplementedError, 'Linux Landlock ABI 3 or newer is required' unless RUBY_PLATFORM.match?(/(?:x86_64|aarch64).*linux/)
|
|
504
|
+
|
|
505
|
+
abi = syscall(444, 0, 0, 1)
|
|
506
|
+
raise NotImplementedError, 'Linux Landlock ABI 3 or newer is required' if abi < 3
|
|
507
|
+
|
|
508
|
+
# Handle every filesystem content/creation/removal right, including
|
|
509
|
+
# REFER and TRUNCATE. Read and execute remain available everywhere.
|
|
510
|
+
rights = (1 << 1) | (((1 << 15) - 1) & ~15)
|
|
511
|
+
attr = ::FFI::MemoryPointer.from_string([rights].pack('Q'))
|
|
512
|
+
ruleset = syscall(444, attr.address, 8, 0)
|
|
513
|
+
roots = opts.fetch(:roots)
|
|
514
|
+
# Compile exact denies and their ancestors once. Hardlink inventories can
|
|
515
|
+
# contain thousands of files; rescanning every deny at every tree node
|
|
516
|
+
# makes otherwise bounded namespace startup quadratic.
|
|
517
|
+
denied = roots.to_h { |path| [path, true] }
|
|
518
|
+
ancestors = {}
|
|
519
|
+
roots.each do |root|
|
|
520
|
+
parent = File.dirname(root)
|
|
521
|
+
loop do
|
|
522
|
+
ancestors[parent] = true
|
|
523
|
+
break if parent == '/'
|
|
524
|
+
|
|
525
|
+
parent = File.dirname(parent)
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
begin
|
|
529
|
+
opts.fetch(:writable).each do |path|
|
|
530
|
+
next if roots.any? { |root| path == root || path.start_with?("#{root}/") }
|
|
531
|
+
|
|
532
|
+
repository_allow!(path: path, denied: denied, ancestors: ancestors, ruleset: ruleset, rights: rights, force: opts[:explicit]&.include?(path))
|
|
533
|
+
end
|
|
534
|
+
prctl = RUBY_PLATFORM.start_with?('aarch64') ? 167 : 157
|
|
535
|
+
syscall(prctl, 38, 1, 0, 0, 0)
|
|
536
|
+
syscall(446, ruleset, 0)
|
|
537
|
+
ensure
|
|
538
|
+
IO.new(ruleset).close
|
|
539
|
+
end
|
|
540
|
+
:restricted
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
private_class_method def self.repository_allow!(opts = {})
|
|
544
|
+
path = opts.fetch(:path)
|
|
545
|
+
return if opts.fetch(:denied).key?(path)
|
|
546
|
+
return if File.symlink?(path)
|
|
547
|
+
|
|
548
|
+
if opts.fetch(:ancestors).key?(path) && !opts[:force]
|
|
549
|
+
Dir.children(path).each { |name| repository_allow!(opts.merge(path: File.join(path, name), force: false)) }
|
|
550
|
+
else
|
|
551
|
+
rights = opts.fetch(:rights)
|
|
552
|
+
rights &= (1 << 1) | (1 << 14) unless File.directory?(path)
|
|
553
|
+
fd = IO.sysopen(path, 0x200000 | 0x80000) # O_PATH | O_CLOEXEC
|
|
554
|
+
begin
|
|
555
|
+
attr = ::FFI::MemoryPointer.from_string([rights, fd].pack('QL'))
|
|
556
|
+
syscall(445, opts.fetch(:ruleset), 1, attr.address, 0)
|
|
557
|
+
ensure
|
|
558
|
+
IO.new(fd).close
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
:allowed
|
|
562
|
+
rescue Errno::ENOENT
|
|
563
|
+
# Unrelated sibling scratch entries can disappear during enumeration.
|
|
564
|
+
# Omitting an allow rule is conservative; never grant its parent.
|
|
565
|
+
:absent
|
|
566
|
+
end
|
|
567
|
+
|
|
64
568
|
public_class_method def self.authors
|
|
65
569
|
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
66
570
|
end
|
|
67
571
|
|
|
68
572
|
public_class_method def self.help
|
|
69
573
|
puts "USAGE:
|
|
574
|
+
# Pin controller ancestry with no-follow openat directory descriptors.
|
|
575
|
+
#{self}.control_directory(
|
|
576
|
+
path: 'required - controller directory, created with mode 0700 if absent'
|
|
577
|
+
) { |fd_path| persist_with_nofollow(fd_path) }
|
|
578
|
+
|
|
579
|
+
# Execute a runtime handler behind mandatory Linux source protection.
|
|
580
|
+
#{self}.protect_call(
|
|
581
|
+
roots: 'optional - host-owned protected roots; defaults to checkout and loaded package',
|
|
582
|
+
workspace: 'optional - host-owned cwd; defaults to ~/.pwn/workspaces/runtime',
|
|
583
|
+
write_roots: 'optional - destinations named by the original operator request',
|
|
584
|
+
runtime: 'optional - host-owned request runtime; child checkpoint writes stay isolated'
|
|
585
|
+
) { handler.call }
|
|
586
|
+
|
|
587
|
+
# Request one typed swarm operation from the supervising controller.
|
|
588
|
+
#{self}.swarm_control(
|
|
589
|
+
operation: 'required - spawn or ask; no arbitrary method or handler dispatch',
|
|
590
|
+
args: 'required - JSON-compatible allowlisted swarm tool arguments'
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
# Return the default directory for unnamed new files.
|
|
594
|
+
#{self}.workspace
|
|
595
|
+
|
|
596
|
+
# Return the task directory used for relative reads.
|
|
597
|
+
#{self}.task_directory
|
|
598
|
+
|
|
599
|
+
# Create and return grants for operator-named write destinations.
|
|
600
|
+
#{self}.prepare_write_roots(
|
|
601
|
+
paths: 'required - absolute or expandable destinations from the original request'
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
# Return detected checkout and installed package source roots.
|
|
605
|
+
#{self}.protected_roots
|
|
606
|
+
|
|
70
607
|
# List host binaries this module expects to be installed.
|
|
71
608
|
#{self}.required_bins
|
|
72
609
|
|
|
@@ -7,7 +7,11 @@ module PWN
|
|
|
7
7
|
# This plugin is used to assist in masking a password when entered in via
|
|
8
8
|
# STDIN to prevent would-be shoulder surfers from obtaining password
|
|
9
9
|
# information. This plugin is useful when demonstrating the functionality
|
|
10
|
-
# of other SP plugins/modules.
|
|
10
|
+
# of other SP plugins/modules. MFA can explicitly select a YubiKey OATH
|
|
11
|
+
# account without storing its secret; device failures never fall back to a prompt.
|
|
12
|
+
# Native OATH requires explicit confirm: true because HOTP advances counters
|
|
13
|
+
# and password-protected accounts can require authentication. Passwords and
|
|
14
|
+
# secret providers remain in memory; no automatic deletion or repair occurs.
|
|
11
15
|
module AuthenticationHelper
|
|
12
16
|
# Supported Method Parameters::
|
|
13
17
|
# PWN::Plugins::AuthenticationHelper.username(
|
|
@@ -40,20 +44,74 @@ module PWN
|
|
|
40
44
|
|
|
41
45
|
# Supported Method Parameters::
|
|
42
46
|
# PWN::Plugins::AuthenticationHelper.mfa(
|
|
43
|
-
# prompt: 'optional - string to display at prompt (Default: MFA Token)'
|
|
47
|
+
# prompt: 'optional - string to display at prompt (Default: MFA Token)',
|
|
48
|
+
# yubikey: 'optional - Hash selecting an exact OATH account (required :account and literal :confirm true); optional :serial exact decimal String, :reader or :device_path nonempty control-free String, :connection native exchange object, :timeout finite positive seconds (default 30), in-memory :password or callable :secret_provider; serial/reader and reader/device_path are mutually exclusive; no OTP slot; explicit mfa/mfa_token values conflict'
|
|
44
49
|
# )
|
|
45
50
|
|
|
46
51
|
public_class_method def self.mfa(opts = {})
|
|
52
|
+
validate_mfa_options(opts)
|
|
53
|
+
return PWN::Plugins::YubiKey.oath_code(opts[:yubikey]) unless opts[:yubikey].nil?
|
|
54
|
+
|
|
47
55
|
prompt = opts[:prompt] ||= 'MFA Token'
|
|
48
56
|
|
|
49
57
|
mfa = TTY::Prompt.new.ask("#{prompt}: ")
|
|
50
58
|
mfa.to_s.strip.chomp.scrub
|
|
51
59
|
rescue Interrupt
|
|
60
|
+
raise unless opts[:yubikey].nil?
|
|
61
|
+
|
|
52
62
|
puts "#{self}.#{__method__} => Goodbye."
|
|
53
63
|
rescue StandardError => e
|
|
54
64
|
raise e
|
|
55
65
|
end
|
|
56
66
|
|
|
67
|
+
# Validate MFA source selection before login without touching a device.
|
|
68
|
+
public_class_method def self.validate_mfa_options(opts = {})
|
|
69
|
+
yubikey = opts[:yubikey]
|
|
70
|
+
return opts if yubikey.nil?
|
|
71
|
+
|
|
72
|
+
raise ArgumentError, 'yubikey must be a Hash with an exact OATH account' unless yubikey.is_a?(Hash)
|
|
73
|
+
raise ArgumentError, 'yubikey slot is not an OATH option; use the exact account name' if yubikey.key?(:slot) || yubikey.key?('slot')
|
|
74
|
+
|
|
75
|
+
account = yubikey[:account]
|
|
76
|
+
raise ArgumentError, 'yubikey account must be a nonempty exact OATH name without control characters' unless account.is_a?(String) && account.valid_encoding? && !account.strip.empty? && !account.match?(/[[:cntrl:]]/)
|
|
77
|
+
|
|
78
|
+
raise ArgumentError, 'yubikey cannot be combined with an explicit mfa or mfa_token' if !opts[:mfa_token].to_s.strip.empty? || (opts[:mfa].is_a?(String) && !opts[:mfa].strip.empty?)
|
|
79
|
+
raise ArgumentError, 'yubikey requires confirm: true: HOTP advances counters and protected OATH accounts may require authentication' unless yubikey[:confirm] == true
|
|
80
|
+
|
|
81
|
+
validate_native_yubikey_options(yubikey: yubikey)
|
|
82
|
+
opts
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private_class_method def self.validate_native_yubikey_options(opts = {})
|
|
86
|
+
selector = opts[:yubikey]
|
|
87
|
+
allowed = %i[account confirm serial reader device_path connection timeout password secret_provider]
|
|
88
|
+
raise ArgumentError, 'Unknown yubikey OATH selector option' unless (selector.keys - allowed).empty?
|
|
89
|
+
|
|
90
|
+
serial = selector[:serial]
|
|
91
|
+
raise ArgumentError, 'yubikey serial must be an exact decimal String' unless serial.nil? || (serial.is_a?(String) && serial.valid_encoding? && serial.match?(/\A[0-9]+\z/))
|
|
92
|
+
|
|
93
|
+
timeout = selector[:timeout]
|
|
94
|
+
raise ArgumentError, 'yubikey timeout must be finite positive seconds' unless timeout.nil? || ((timeout.is_a?(Integer) || timeout.is_a?(Float)) && timeout.finite? && timeout.positive?)
|
|
95
|
+
|
|
96
|
+
%i[reader device_path].each do |field|
|
|
97
|
+
name = selector[field]
|
|
98
|
+
raise ArgumentError, "yubikey #{field} must be a nonempty String without control characters" unless name.nil? || (name.is_a?(String) && name.valid_encoding? && !name.empty? && !name.match?(/[[:cntrl:]]/))
|
|
99
|
+
end
|
|
100
|
+
raise ArgumentError, 'yubikey serial and reader selectors are mutually exclusive' if !serial.nil? && !selector[:reader].nil?
|
|
101
|
+
raise ArgumentError, 'yubikey reader and device_path selectors are mutually exclusive' if !selector[:reader].nil? && !selector[:device_path].nil?
|
|
102
|
+
|
|
103
|
+
connection = selector[:connection]
|
|
104
|
+
raise ArgumentError, 'yubikey connection must respond to exchange' unless connection.nil? || connection.respond_to?(:exchange)
|
|
105
|
+
|
|
106
|
+
password = selector[:password]
|
|
107
|
+
raise ArgumentError, 'yubikey password must be a nonempty in-memory String' unless password.nil? || (password.is_a?(String) && !password.empty?)
|
|
108
|
+
|
|
109
|
+
provider = selector[:secret_provider]
|
|
110
|
+
raise ArgumentError, 'yubikey secret_provider must respond to call' unless provider.nil? || provider.respond_to?(:call)
|
|
111
|
+
|
|
112
|
+
selector
|
|
113
|
+
end
|
|
114
|
+
|
|
57
115
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
58
116
|
|
|
59
117
|
public_class_method def self.authors
|
|
@@ -74,9 +132,21 @@ module PWN
|
|
|
74
132
|
prompt: 'optional - string to display at prompt (Default: Password)'
|
|
75
133
|
)
|
|
76
134
|
|
|
77
|
-
#
|
|
135
|
+
# Return a manual MFA token or exact-account YubiKey OATH digits without logging them.
|
|
78
136
|
#{self}.mfa(
|
|
79
|
-
prompt: 'optional - string to display at prompt (Default: MFA Token)'
|
|
137
|
+
prompt: 'optional - string to display at the manual prompt (Default: MFA Token)',
|
|
138
|
+
yubikey: 'optional - Hash selecting an exact OATH account (required :account and literal :confirm true); optional :serial exact decimal String, :reader or :device_path nonempty control-free String, :connection native exchange object, :timeout finite positive seconds (default 30), in-memory :password or callable :secret_provider; serial/reader and reader/device_path are mutually exclusive; no OTP slot; explicit mfa/mfa_token values conflict'
|
|
139
|
+
)
|
|
140
|
+
# Example yubikey: { account: 'EXACT_OATH_NAME', confirm: true, serial: '123456', timeout: 30 }
|
|
141
|
+
# Explicit consent is required: HOTP advances counters and protected OATH accounts may require authentication. No automatic repair or deletion occurs.
|
|
142
|
+
# The account name is passed unchanged. Device failures and cancellation propagate; no manual fallback.
|
|
143
|
+
# OATH credentials are distinct from OTP slots, SMS codes, and FIDO2/WebAuthn security keys.
|
|
144
|
+
|
|
145
|
+
# Validate MFA source selection before login without prompting or accessing a device; return opts.
|
|
146
|
+
#{self}.validate_mfa_options(
|
|
147
|
+
yubikey: 'optional - Hash selecting an exact OATH account (required :account and literal :confirm true); optional :serial exact decimal String, :reader or :device_path nonempty control-free String, :connection native exchange object, :timeout finite positive seconds (default 30), in-memory :password or callable :secret_provider; serial/reader and reader/device_path are mutually exclusive; no OTP slot; explicit mfa/mfa_token values conflict',
|
|
148
|
+
mfa: 'optional - browser MFA flag or explicit token string; a nonblank token conflicts with YubiKey',
|
|
149
|
+
mfa_token: 'optional - explicit browser MFA token; a nonblank value conflicts with YubiKey'
|
|
80
150
|
)
|
|
81
151
|
|
|
82
152
|
# Print the AUTHOR(S) string for this module.
|