inspect-ranges 0.2.0__py3-none-any.whl
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.
- inspect_ranges/__init__.py +105 -0
- inspect_ranges/__main__.py +6 -0
- inspect_ranges/_attackbox/__init__.py +48 -0
- inspect_ranges/_attackbox/attach.py +293 -0
- inspect_ranges/_attackbox/backend.py +218 -0
- inspect_ranges/_attackbox/conformance.py +480 -0
- inspect_ranges/_attackbox/record.py +90 -0
- inspect_ranges/_brand.py +21 -0
- inspect_ranges/_build/__init__.py +121 -0
- inspect_ranges/_build/activities.py +420 -0
- inspect_ranges/_build/behavioral.py +775 -0
- inspect_ranges/_build/capture.py +368 -0
- inspect_ranges/_build/errors.py +26 -0
- inspect_ranges/_build/guesttool.py +292 -0
- inspect_ranges/_build/manifest.py +658 -0
- inspect_ranges/_build/orchestrate.py +1943 -0
- inspect_ranges/_build/recipes.py +671 -0
- inspect_ranges/_build/verify.py +611 -0
- inspect_ranges/_challenges/__init__.py +175 -0
- inspect_ranges/_challenges/attach.py +202 -0
- inspect_ranges/_challenges/corroborate.py +294 -0
- inspect_ranges/_challenges/dataset.py +121 -0
- inspect_ranges/_challenges/engine.py +362 -0
- inspect_ranges/_challenges/evidenceread.py +118 -0
- inspect_ranges/_challenges/goaltypes.py +275 -0
- inspect_ranges/_challenges/netident.py +30 -0
- inspect_ranges/_challenges/observedtypes.py +888 -0
- inspect_ranges/_challenges/oracle.py +513 -0
- inspect_ranges/_challenges/pcapscan.py +78 -0
- inspect_ranges/_challenges/plants.py +120 -0
- inspect_ranges/_challenges/poller.py +283 -0
- inspect_ranges/_challenges/prober.py +197 -0
- inspect_ranges/_challenges/registration.py +30 -0
- inspect_ranges/_challenges/schema.py +1058 -0
- inspect_ranges/_challenges/scorer.py +466 -0
- inspect_ranges/_challenges/setup.py +461 -0
- inspect_ranges/_challenges/submission.py +81 -0
- inspect_ranges/_challenges/timings.py +58 -0
- inspect_ranges/_challenges/verify.py +242 -0
- inspect_ranges/_channel/__init__.py +95 -0
- inspect_ranges/_channel/bundle.py +275 -0
- inspect_ranges/_channel/channel.py +1775 -0
- inspect_ranges/_channel/codec.py +536 -0
- inspect_ranges/_channel/daemon/linux/README.md +44 -0
- inspect_ranges/_channel/daemon/windows/README.md +29 -0
- inspect_ranges/_channel/mocks.py +424 -0
- inspect_ranges/_channel/protocol.py +499 -0
- inspect_ranges/_channel/uds.py +154 -0
- inspect_ranges/_channel/vsock.py +166 -0
- inspect_ranges/_cli/__init__.py +0 -0
- inspect_ranges/_cli/appliercmd.py +91 -0
- inspect_ranges/_cli/attackboxcmd.py +77 -0
- inspect_ranges/_cli/buildcmd.py +197 -0
- inspect_ranges/_cli/daemonbundle.py +64 -0
- inspect_ranges/_cli/distcmd.py +469 -0
- inspect_ranges/_cli/doctor.py +29 -0
- inspect_ranges/_cli/evidencecmd.py +116 -0
- inspect_ranges/_cli/imagescmd.py +598 -0
- inspect_ranges/_cli/main.py +48 -0
- inspect_ranges/_cli/plancmd.py +213 -0
- inspect_ranges/_cli/reapercmd.py +54 -0
- inspect_ranges/_cli/schema.py +11 -0
- inspect_ranges/_cli/specpath.py +19 -0
- inspect_ranges/_cli/upcmd.py +181 -0
- inspect_ranges/_cli/validate.py +75 -0
- inspect_ranges/_compiler/__init__.py +32 -0
- inspect_ranges/_compiler/acl.py +168 -0
- inspect_ranges/_compiler/allocate.py +279 -0
- inspect_ranges/_compiler/dnsmasq.py +112 -0
- inspect_ranges/_compiler/draws.py +50 -0
- inspect_ranges/_compiler/egress.py +109 -0
- inspect_ranges/_compiler/nftables.py +104 -0
- inspect_ranges/_compiler/plan.py +760 -0
- inspect_ranges/_compiler/probe.py +135 -0
- inspect_ranges/_compiler/render.py +959 -0
- inspect_ranges/_compiler/routing.py +43 -0
- inspect_ranges/_devbox/__init__.py +915 -0
- inspect_ranges/_devbox/bootstrap.sh +149 -0
- inspect_ranges/_devbox/idle-check.sh +49 -0
- inspect_ranges/_diagnostics.py +621 -0
- inspect_ranges/_dist/__init__.py +54 -0
- inspect_ranges/_dist/catalog.py +510 -0
- inspect_ranges/_dist/encode.py +435 -0
- inspect_ranges/_dist/errors.py +12 -0
- inspect_ranges/_dist/gc.py +139 -0
- inspect_ranges/_dist/location.py +165 -0
- inspect_ranges/_dist/materialize.py +668 -0
- inspect_ranges/_dist/mirror.py +216 -0
- inspect_ranges/_dist/oci.py +666 -0
- inspect_ranges/_dist/progress.py +94 -0
- inspect_ranges/_dist/registry.py +775 -0
- inspect_ranges/_dist/resolve.py +556 -0
- inspect_ranges/_dist/transfer.py +87 -0
- inspect_ranges/_doctor/__init__.py +15 -0
- inspect_ranges/_doctor/checks.py +1532 -0
- inspect_ranges/_doctor/report.py +99 -0
- inspect_ranges/_doctor/result.py +28 -0
- inspect_ranges/_doctor/s3probe.py +55 -0
- inspect_ranges/_evidence/__init__.py +130 -0
- inspect_ranges/_evidence/capture.py +743 -0
- inspect_ranges/_evidence/execwitness.py +309 -0
- inspect_ranges/_evidence/location.py +122 -0
- inspect_ranges/_evidence/manifest.py +193 -0
- inspect_ranges/_evidence/reader.py +496 -0
- inspect_ranges/_evidence/s3.py +214 -0
- inspect_ranges/_evidence/sealer.py +149 -0
- inspect_ranges/_evidence/sink.py +624 -0
- inspect_ranges/_evidence/supervisor.py +241 -0
- inspect_ranges/_evidence/telemetry.py +712 -0
- inspect_ranges/_host/__init__.py +66 -0
- inspect_ranges/_host/applier.py +810 -0
- inspect_ranges/_host/backend.py +204 -0
- inspect_ranges/_host/host.py +97 -0
- inspect_ranges/_host/leases.py +214 -0
- inspect_ranges/_host/local.py +223 -0
- inspect_ranges/_host/reaper.py +176 -0
- inspect_ranges/_host/remote.py +106 -0
- inspect_ranges/_host/seams.py +188 -0
- inspect_ranges/_host/stages.py +79 -0
- inspect_ranges/_metadata.py +10 -0
- inspect_ranges/_provider/__init__.py +26 -0
- inspect_ranges/_provider/admission.py +150 -0
- inspect_ranges/_provider/errors.py +135 -0
- inspect_ranges/_provider/evidence.py +570 -0
- inspect_ranges/_provider/naming.py +148 -0
- inspect_ranges/_provider/ops.py +415 -0
- inspect_ranges/_provider/provider.py +911 -0
- inspect_ranges/_provider/retry.py +283 -0
- inspect_ranges/_provider/state.py +325 -0
- inspect_ranges/_registry.py +36 -0
- inspect_ranges/_runtime/__init__.py +35 -0
- inspect_ranges/_runtime/down.py +189 -0
- inspect_ranges/_runtime/images.py +529 -0
- inspect_ranges/_runtime/ownership.py +157 -0
- inspect_ranges/_runtime/range_image/entrypoint.sh +61 -0
- inspect_ranges/_runtime/rangeimage.py +130 -0
- inspect_ranges/_runtime/registry.py +80 -0
- inspect_ranges/_runtime/up.py +1094 -0
- inspect_ranges/_runtime/winimage.py +531 -0
- inspect_ranges/_scanners/__init__.py +1 -0
- inspect_ranges/_scanners/engine.py +773 -0
- inspect_ranges/_scanners/reachability.py +112 -0
- inspect_ranges/_terminal/__init__.py +5 -0
- inspect_ranges/_terminal/capture.py +280 -0
- inspect_ranges/_terminal/cast.py +108 -0
- inspect_ranges/_terminal/session.py +395 -0
- inspect_ranges/_terminal/tool.py +341 -0
- inspect_ranges/_version.py +24 -0
- inspect_ranges/attackbox.py +44 -0
- inspect_ranges/examples/__init__.py +26 -0
- inspect_ranges/examples/_cyris_basic/README.md +132 -0
- inspect_ranges/examples/_cyris_basic/__init__.py +1 -0
- inspect_ranges/examples/_cyris_basic/challenges.yaml +85 -0
- inspect_ranges/examples/_cyris_basic/checks/cross-segment-recon/forged.sh +4 -0
- inspect_ranges/examples/_cyris_basic/checks/cross-segment-recon/insufficient.sh +6 -0
- inspect_ranges/examples/_cyris_basic/checks/cross-segment-recon/reference.sh +34 -0
- inspect_ranges/examples/_cyris_basic/checks/firewall-pivot/forged.sh +4 -0
- inspect_ranges/examples/_cyris_basic/checks/firewall-pivot/insufficient.sh +7 -0
- inspect_ranges/examples/_cyris_basic/checks/firewall-pivot/reference.sh +97 -0
- inspect_ranges/examples/_cyris_basic/checks/forensics-triage/forged.sh +6 -0
- inspect_ranges/examples/_cyris_basic/checks/forensics-triage/insufficient.sh +9 -0
- inspect_ranges/examples/_cyris_basic/checks/forensics-triage/reference.sh +16 -0
- inspect_ranges/examples/_cyris_basic/checks/local-privesc-flag/forged.sh +4 -0
- inspect_ranges/examples/_cyris_basic/checks/local-privesc-flag/insufficient.sh +6 -0
- inspect_ranges/examples/_cyris_basic/checks/local-privesc-flag/reference.sh +62 -0
- inspect_ranges/examples/_cyris_basic/cyris-basic-topology.svg +16 -0
- inspect_ranges/examples/_cyris_basic/deferred.yaml +89 -0
- inspect_ranges/examples/_cyris_basic/notes.md +51 -0
- inspect_ranges/examples/_cyris_basic/range.yaml +87 -0
- inspect_ranges/examples/_cyris_basic/recipes/cyris.desktop/recipe.yaml +4 -0
- inspect_ranges/examples/_cyris_basic/recipes/cyris.desktop/run.sh +84 -0
- inspect_ranges/examples/_cyris_basic/recipes/cyris.firewall/recipe.yaml +4 -0
- inspect_ranges/examples/_cyris_basic/recipes/cyris.firewall/run.sh +36 -0
- inspect_ranges/examples/_cyris_basic/recipes/cyris.webserver/recipe.yaml +4 -0
- inspect_ranges/examples/_cyris_basic/recipes/cyris.webserver/run.sh +79 -0
- inspect_ranges/examples/_cyris_basic/source.md +15 -0
- inspect_ranges/examples/_cyris_basic/task.py +28 -0
- inspect_ranges/examples/_dmz_pivot/README.md +149 -0
- inspect_ranges/examples/_dmz_pivot/__init__.py +1 -0
- inspect_ranges/examples/_dmz_pivot/challenges.yaml +47 -0
- inspect_ranges/examples/_dmz_pivot/checks/crown-jewels/forged.sh +5 -0
- inspect_ranges/examples/_dmz_pivot/checks/crown-jewels/insufficient.sh +13 -0
- inspect_ranges/examples/_dmz_pivot/checks/crown-jewels/reference.sh +100 -0
- inspect_ranges/examples/_dmz_pivot/checks/customer-data/forged.sh +5 -0
- inspect_ranges/examples/_dmz_pivot/checks/customer-data/insufficient.sh +18 -0
- inspect_ranges/examples/_dmz_pivot/checks/customer-data/reference.sh +102 -0
- inspect_ranges/examples/_dmz_pivot/checks/web-foothold/forged.sh +5 -0
- inspect_ranges/examples/_dmz_pivot/checks/web-foothold/insufficient.sh +22 -0
- inspect_ranges/examples/_dmz_pivot/checks/web-foothold/reference.sh +128 -0
- inspect_ranges/examples/_dmz_pivot/dmz-pivot-topology.svg +16 -0
- inspect_ranges/examples/_dmz_pivot/notes.md +48 -0
- inspect_ranges/examples/_dmz_pivot/range.yaml +115 -0
- inspect_ranges/examples/_dmz_pivot/recipes/dmz.db/recipe.yaml +4 -0
- inspect_ranges/examples/_dmz_pivot/recipes/dmz.db/run.sh +40 -0
- inspect_ranges/examples/_dmz_pivot/recipes/dmz.web/recipe.yaml +4 -0
- inspect_ranges/examples/_dmz_pivot/recipes/dmz.web/run.sh +51 -0
- inspect_ranges/examples/_dmz_pivot/source.md +8 -0
- inspect_ranges/examples/_dmz_pivot/task.py +28 -0
- inspect_ranges/examples/_equifax/README.md +131 -0
- inspect_ranges/examples/_equifax/__init__.py +1 -0
- inspect_ranges/examples/_equifax/challenges.yaml +55 -0
- inspect_ranges/examples/_equifax/checks/exfiltrate-critical-data/forged.sh +7 -0
- inspect_ranges/examples/_equifax/checks/exfiltrate-critical-data/insufficient.sh +53 -0
- inspect_ranges/examples/_equifax/checks/exfiltrate-critical-data/reference.sh +107 -0
- inspect_ranges/examples/_equifax/deferred.yaml +83 -0
- inspect_ranges/examples/_equifax/equifax-topology.svg +16 -0
- inspect_ranges/examples/_equifax/notes.md +55 -0
- inspect_ranges/examples/_equifax/range.yaml +191 -0
- inspect_ranges/examples/_equifax/recipes/mh.database/payload/pivot_key.pub +1 -0
- inspect_ranges/examples/_equifax/recipes/mh.database/recipe.yaml +7 -0
- inspect_ranges/examples/_equifax/recipes/mh.database/run.sh +34 -0
- inspect_ranges/examples/_equifax/recipes/mh.webserver/payload/pivot_key +8 -0
- inspect_ranges/examples/_equifax/recipes/mh.webserver/payload/pivot_key.pub +1 -0
- inspect_ranges/examples/_equifax/recipes/mh.webserver/payload/struts_probe.sh +14 -0
- inspect_ranges/examples/_equifax/recipes/mh.webserver/recipe.yaml +7 -0
- inspect_ranges/examples/_equifax/recipes/mh.webserver/run.sh +71 -0
- inspect_ranges/examples/_equifax/source.md +20 -0
- inspect_ranges/examples/_equifax/task.py +28 -0
- inspect_ranges/examples/_goad_light/README.md +134 -0
- inspect_ranges/examples/_goad_light/__init__.py +1 -0
- inspect_ranges/examples/_goad_light/challenges.yaml +55 -0
- inspect_ranges/examples/_goad_light/checks/ad-domain-dominance/forged.sh +8 -0
- inspect_ranges/examples/_goad_light/checks/ad-domain-dominance/insufficient.sh +34 -0
- inspect_ranges/examples/_goad_light/checks/ad-domain-dominance/reference.sh +88 -0
- inspect_ranges/examples/_goad_light/deferred.yaml +59 -0
- inspect_ranges/examples/_goad_light/goad-light-topology.svg +16 -0
- inspect_ranges/examples/_goad_light/notes.md +90 -0
- inspect_ranges/examples/_goad_light/range.yaml +182 -0
- inspect_ranges/examples/_goad_light/recipes/goad.dc01-forest/promote.ps1 +14 -0
- inspect_ranges/examples/_goad_light/recipes/goad.dc01-forest/realize.ps1 +259 -0
- inspect_ranges/examples/_goad_light/recipes/goad.dc01-forest/recipe.yaml +4 -0
- inspect_ranges/examples/_goad_light/recipes/goad.dc01-forest/run.sh +52 -0
- inspect_ranges/examples/_goad_light/recipes/goad.dc02-child/promote.ps1 +23 -0
- inspect_ranges/examples/_goad_light/recipes/goad.dc02-child/realize.ps1 +163 -0
- inspect_ranges/examples/_goad_light/recipes/goad.dc02-child/recipe.yaml +4 -0
- inspect_ranges/examples/_goad_light/recipes/goad.dc02-child/run.sh +60 -0
- inspect_ranges/examples/_goad_light/recipes/goad.srv02-member/join.ps1 +14 -0
- inspect_ranges/examples/_goad_light/recipes/goad.srv02-member/mssql.ps1 +74 -0
- inspect_ranges/examples/_goad_light/recipes/goad.srv02-member/realize.ps1 +113 -0
- inspect_ranges/examples/_goad_light/recipes/goad.srv02-member/recipe.yaml +4 -0
- inspect_ranges/examples/_goad_light/recipes/goad.srv02-member/run.sh +60 -0
- inspect_ranges/examples/_goad_light/source.md +17 -0
- inspect_ranges/examples/_goad_light/task.py +28 -0
- inspect_ranges/examples/_kypo_demo/README.md +132 -0
- inspect_ranges/examples/_kypo_demo/__init__.py +1 -0
- inspect_ranges/examples/_kypo_demo/challenges.yaml +51 -0
- inspect_ranges/examples/_kypo_demo/checks/telnet-ladder/forged.sh +8 -0
- inspect_ranges/examples/_kypo_demo/checks/telnet-ladder/insufficient.sh +11 -0
- inspect_ranges/examples/_kypo_demo/checks/telnet-ladder/reference.sh +126 -0
- inspect_ranges/examples/_kypo_demo/deferred.yaml +58 -0
- inspect_ranges/examples/_kypo_demo/kypo-demo-topology.svg +16 -0
- inspect_ranges/examples/_kypo_demo/notes.md +47 -0
- inspect_ranges/examples/_kypo_demo/range.yaml +95 -0
- inspect_ranges/examples/_kypo_demo/recipes/kypo.client/recipe.yaml +4 -0
- inspect_ranges/examples/_kypo_demo/recipes/kypo.client/run.sh +51 -0
- inspect_ranges/examples/_kypo_demo/recipes/kypo.server/recipe.yaml +7 -0
- inspect_ranges/examples/_kypo_demo/recipes/kypo.server/run.sh +80 -0
- inspect_ranges/examples/_kypo_demo/source.md +17 -0
- inspect_ranges/examples/_kypo_demo/task.py +28 -0
- inspect_ranges/examples/_splunk_detection/README.md +126 -0
- inspect_ranges/examples/_splunk_detection/__init__.py +1 -0
- inspect_ranges/examples/_splunk_detection/activities/benign-admin.ps1 +26 -0
- inspect_ranges/examples/_splunk_detection/challenges.yaml +144 -0
- inspect_ranges/examples/_splunk_detection/checks/evade-detection-t1003-001/forged.sh +84 -0
- inspect_ranges/examples/_splunk_detection/checks/evade-detection-t1003-001/insufficient.sh +40 -0
- inspect_ranges/examples/_splunk_detection/checks/evade-detection-t1003-001/reference.sh +130 -0
- inspect_ranges/examples/_splunk_detection/checks/execute-and-confirm-t1003-001/forged.sh +47 -0
- inspect_ranges/examples/_splunk_detection/checks/execute-and-confirm-t1003-001/insufficient.sh +31 -0
- inspect_ranges/examples/_splunk_detection/checks/execute-and-confirm-t1003-001/reference.sh +41 -0
- inspect_ranges/examples/_splunk_detection/collectors.yaml +21 -0
- inspect_ranges/examples/_splunk_detection/deferred.yaml +54 -0
- inspect_ranges/examples/_splunk_detection/notes.md +110 -0
- inspect_ranges/examples/_splunk_detection/range.yaml +83 -0
- inspect_ranges/examples/_splunk_detection/recipes/arw.splunk-server/recipe.yaml +4 -0
- inspect_ranges/examples/_splunk_detection/recipes/arw.splunk-server/run.sh +125 -0
- inspect_ranges/examples/_splunk_detection/recipes/arw.win-endpoint/endpoint.ps1 +310 -0
- inspect_ranges/examples/_splunk_detection/recipes/arw.win-endpoint/recipe.yaml +6 -0
- inspect_ranges/examples/_splunk_detection/recipes/arw.win-endpoint/run.sh +15 -0
- inspect_ranges/examples/_splunk_detection/source.md +14 -0
- inspect_ranges/examples/_splunk_detection/splunk-detection-topology.svg +16 -0
- inspect_ranges/examples/_splunk_detection/task.py +31 -0
- inspect_ranges/examples/_vulhub_zabbix/README.md +118 -0
- inspect_ranges/examples/_vulhub_zabbix/__init__.py +1 -0
- inspect_ranges/examples/_vulhub_zabbix/challenges.yaml +36 -0
- inspect_ranges/examples/_vulhub_zabbix/checks/zabbix-rce/forged.sh +6 -0
- inspect_ranges/examples/_vulhub_zabbix/checks/zabbix-rce/insufficient.sh +23 -0
- inspect_ranges/examples/_vulhub_zabbix/checks/zabbix-rce/reference.sh +105 -0
- inspect_ranges/examples/_vulhub_zabbix/deferred.yaml +29 -0
- inspect_ranges/examples/_vulhub_zabbix/notes.md +54 -0
- inspect_ranges/examples/_vulhub_zabbix/range.yaml +86 -0
- inspect_ranges/examples/_vulhub_zabbix/recipes/vulhub.mysql57/recipe.yaml +7 -0
- inspect_ranges/examples/_vulhub_zabbix/recipes/vulhub.mysql57/run.sh +73 -0
- inspect_ranges/examples/_vulhub_zabbix/recipes/vulhub.zabbix-agent/recipe.yaml +7 -0
- inspect_ranges/examples/_vulhub_zabbix/recipes/vulhub.zabbix-agent/run.sh +51 -0
- inspect_ranges/examples/_vulhub_zabbix/recipes/vulhub.zabbix-server/recipe.yaml +7 -0
- inspect_ranges/examples/_vulhub_zabbix/recipes/vulhub.zabbix-server/run.sh +64 -0
- inspect_ranges/examples/_vulhub_zabbix/recipes/vulhub.zabbix-web/recipe.yaml +9 -0
- inspect_ranges/examples/_vulhub_zabbix/recipes/vulhub.zabbix-web/run.sh +60 -0
- inspect_ranges/examples/_vulhub_zabbix/source.md +15 -0
- inspect_ranges/examples/_vulhub_zabbix/task.py +28 -0
- inspect_ranges/examples/_vulhub_zabbix/vulhub-zabbix-topology.svg +16 -0
- inspect_ranges/host.py +82 -0
- inspect_ranges/py.typed +0 -0
- inspect_ranges/scanners.py +85 -0
- inspect_ranges/schema.py +252 -0
- inspect_ranges/types.py +1790 -0
- inspect_ranges-0.2.0.dist-info/METADATA +29 -0
- inspect_ranges-0.2.0.dist-info/RECORD +311 -0
- inspect_ranges-0.2.0.dist-info/WHEEL +4 -0
- inspect_ranges-0.2.0.dist-info/entry_points.txt +5 -0
- inspect_ranges-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# __version__ resolves first: deep imports (the oracle's compiler/runtime
|
|
2
|
+
# chain) read it during package initialization
|
|
3
|
+
try:
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
except ImportError:
|
|
6
|
+
__version__ = "unknown"
|
|
7
|
+
|
|
8
|
+
from ._challenges import (
|
|
9
|
+
PROBE_MAC_PREFIX,
|
|
10
|
+
AnswerGoal,
|
|
11
|
+
AvailabilityGoal,
|
|
12
|
+
Challenge,
|
|
13
|
+
Challenges,
|
|
14
|
+
ControlGoal,
|
|
15
|
+
DegradeGoal,
|
|
16
|
+
DetectionGoal,
|
|
17
|
+
EvadeGoal,
|
|
18
|
+
ExecuteGoal,
|
|
19
|
+
ExfiltrateGoal,
|
|
20
|
+
FlagGoal,
|
|
21
|
+
Goal,
|
|
22
|
+
GoalBase,
|
|
23
|
+
GoalResult,
|
|
24
|
+
GoalType,
|
|
25
|
+
HijackGoal,
|
|
26
|
+
JudgeGoal,
|
|
27
|
+
Observations,
|
|
28
|
+
ObserverKind,
|
|
29
|
+
OracleSetupError,
|
|
30
|
+
PatchGoal,
|
|
31
|
+
Primitive,
|
|
32
|
+
RangeOracle,
|
|
33
|
+
Scoring,
|
|
34
|
+
SeverityFilter,
|
|
35
|
+
TamperGoal,
|
|
36
|
+
challenge_dataset,
|
|
37
|
+
challenge_scorer,
|
|
38
|
+
challenge_setup,
|
|
39
|
+
guest_segment_ip,
|
|
40
|
+
load_challenges,
|
|
41
|
+
load_collectors,
|
|
42
|
+
range_oracle,
|
|
43
|
+
register_exploit,
|
|
44
|
+
register_goal_type,
|
|
45
|
+
source_bound_find,
|
|
46
|
+
validate_challenges,
|
|
47
|
+
verify_goal,
|
|
48
|
+
)
|
|
49
|
+
from ._terminal import terminal
|
|
50
|
+
from .schema import (
|
|
51
|
+
load_range,
|
|
52
|
+
range_json_schema,
|
|
53
|
+
revalidate_range,
|
|
54
|
+
validate_range,
|
|
55
|
+
)
|
|
56
|
+
from .types import semantic_issues
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"PROBE_MAC_PREFIX",
|
|
60
|
+
"AnswerGoal",
|
|
61
|
+
"AvailabilityGoal",
|
|
62
|
+
"Challenge",
|
|
63
|
+
"Challenges",
|
|
64
|
+
"ControlGoal",
|
|
65
|
+
"DegradeGoal",
|
|
66
|
+
"DetectionGoal",
|
|
67
|
+
"EvadeGoal",
|
|
68
|
+
"ExecuteGoal",
|
|
69
|
+
"ExfiltrateGoal",
|
|
70
|
+
"FlagGoal",
|
|
71
|
+
"Goal",
|
|
72
|
+
"GoalBase",
|
|
73
|
+
"GoalResult",
|
|
74
|
+
"GoalType",
|
|
75
|
+
"HijackGoal",
|
|
76
|
+
"JudgeGoal",
|
|
77
|
+
"Observations",
|
|
78
|
+
"ObserverKind",
|
|
79
|
+
"OracleSetupError",
|
|
80
|
+
"PatchGoal",
|
|
81
|
+
"Primitive",
|
|
82
|
+
"RangeOracle",
|
|
83
|
+
"Scoring",
|
|
84
|
+
"SeverityFilter",
|
|
85
|
+
"TamperGoal",
|
|
86
|
+
"__version__",
|
|
87
|
+
"challenge_dataset",
|
|
88
|
+
"challenge_scorer",
|
|
89
|
+
"challenge_setup",
|
|
90
|
+
"guest_segment_ip",
|
|
91
|
+
"load_challenges",
|
|
92
|
+
"load_collectors",
|
|
93
|
+
"load_range",
|
|
94
|
+
"range_json_schema",
|
|
95
|
+
"range_oracle",
|
|
96
|
+
"register_exploit",
|
|
97
|
+
"register_goal_type",
|
|
98
|
+
"revalidate_range",
|
|
99
|
+
"semantic_issues",
|
|
100
|
+
"source_bound_find",
|
|
101
|
+
"terminal",
|
|
102
|
+
"validate_challenges",
|
|
103
|
+
"validate_range",
|
|
104
|
+
"verify_goal",
|
|
105
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The attack-box provider seam (`attack-box-v1.md`): an external supplier for one range's dedicated attack box.
|
|
2
|
+
|
|
3
|
+
The range supplies an L2 attachment point on the foothold's segment (taps built inside the range netns, fds handed over) and routes the attacker's exec to the surface the provider returns; the provider supplies the box behind both and owns its lifecycle. Registration mirrors `INSPECT_RANGES_HOST`: the `INSPECT_RANGES_ATTACK_BOX` value is the registration, persisted as the project's attack-box origin so teardown re-resolves from disk. Selection is deployment knowledge, never `range.yaml`. Scope is dedicated attack boxes only: an assumed-breach foothold is a content guest this project builds and captures, excluded by construction.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .backend import (
|
|
7
|
+
ENV_VAR,
|
|
8
|
+
AttachedInterface,
|
|
9
|
+
AttackBoxBackend,
|
|
10
|
+
AttackBoxCapabilities,
|
|
11
|
+
AttackBoxContext,
|
|
12
|
+
AttackBoxFactory,
|
|
13
|
+
AttackBoxHandle,
|
|
14
|
+
AttackBoxProvider,
|
|
15
|
+
AttackBoxReclaimFn,
|
|
16
|
+
FootholdAttachment,
|
|
17
|
+
ParsedAttackBox,
|
|
18
|
+
attack_box_origin,
|
|
19
|
+
parse_attack_box,
|
|
20
|
+
resolve_attack_box,
|
|
21
|
+
)
|
|
22
|
+
from .record import (
|
|
23
|
+
AttackBoxRecord,
|
|
24
|
+
attack_box_record_path,
|
|
25
|
+
read_attack_box_record,
|
|
26
|
+
write_attack_box_record,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"ENV_VAR",
|
|
31
|
+
"AttachedInterface",
|
|
32
|
+
"AttackBoxBackend",
|
|
33
|
+
"AttackBoxCapabilities",
|
|
34
|
+
"AttackBoxContext",
|
|
35
|
+
"AttackBoxFactory",
|
|
36
|
+
"AttackBoxHandle",
|
|
37
|
+
"AttackBoxProvider",
|
|
38
|
+
"AttackBoxReclaimFn",
|
|
39
|
+
"AttackBoxRecord",
|
|
40
|
+
"FootholdAttachment",
|
|
41
|
+
"ParsedAttackBox",
|
|
42
|
+
"attack_box_origin",
|
|
43
|
+
"attack_box_record_path",
|
|
44
|
+
"parse_attack_box",
|
|
45
|
+
"read_attack_box_record",
|
|
46
|
+
"resolve_attack_box",
|
|
47
|
+
"write_attack_box_record",
|
|
48
|
+
]
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""The tap-fd attachment: build the external attack box's NICs inside the range netns and hand the fds out.
|
|
2
|
+
|
|
3
|
+
The invariant this module exists to preserve (`architecture.md`: the host netns carries zero range state while a range runs): the external VMM gets its NIC as an OPEN TAP FD created inside the range container's network namespace, never as a device name it cannot resolve and never as a veth or bridge leg reaching into the host netns. A short verbatim helper (`ATTACH_HELPER_SOURCE`, the capture-supervisor pattern) runs inside the range container as root over the same `docker compose exec` transport as the boot script: it opens `/dev/net/tun`, `TUNSETIFF`s one non-persistent tap per attacker interface (so the fd IS the tap's lifetime), applies the libvirt tap-MAC rule, enslaves each tap to its segment bridge, then connects to a host-side unix listener in the shared vsock directory and ships the fds over `SCM_RIGHTS`. The host receives them in the realizing process and hands them to the provider in-process; the fd plumbing never crosses the public seam.
|
|
4
|
+
|
|
5
|
+
The taps are created with `IFF_VNET_HDR`, matching what QEMU and Cloud Hypervisor request when they create taps themselves, so virtio offload negotiation works for an external VMM consuming the fd; a provider that cannot use the header may still read/write raw frames after `TUNSETVNETHDRSZ`. Teardown needs nothing from this module: a non-persistent tap dies when its last fd closes, and unconditionally when the range container's netns is destroyed; `down` tears the provider first, so the external VMM never outlives its NIC.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import array
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from ipaddress import IPv4Address
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from .._channel.vsock import peer_uid
|
|
20
|
+
from .backend import AttachedInterface, FootholdAttachment
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .._runtime.up import BootGuest
|
|
24
|
+
|
|
25
|
+
ATTACH_TIMEOUT_S = 60.0
|
|
26
|
+
"""How long the host waits for the in-container helper to build the taps and connect back."""
|
|
27
|
+
|
|
28
|
+
ATTACH_HELPER_SOURCE = '''\
|
|
29
|
+
"""Attack-box attach helper: runs as root inside the range container (its netns IS the range netns).
|
|
30
|
+
|
|
31
|
+
Config arrives as JSON in argv[1] (every value shape-validated host-side by `BootPlan` before interpolation). For each interface: open /dev/net/tun, TUNSETIFF a non-persistent IFF_TAP|IFF_NO_PI|IFF_VNET_HDR device, set the fe:-prefixed tap MAC BEFORE enslaving (the bridge adopts its lowest port MAC; a random tap MAC would leak onto the segment), enslave to the segment bridge, bring it up. Then connect to the handoff socket and send one JSON line plus every tap fd via SCM_RIGHTS; wait for the one-byte ack so the fds are known-received before this process (and its fd copies) exits. Any failure tracebacks to stderr and exits nonzero: the host fails the attach stage with it.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import array
|
|
35
|
+
import fcntl
|
|
36
|
+
import json
|
|
37
|
+
import socket
|
|
38
|
+
import struct
|
|
39
|
+
import subprocess
|
|
40
|
+
import sys
|
|
41
|
+
|
|
42
|
+
TUNSETIFF = 0x400454CA
|
|
43
|
+
IFF_TAP = 0x0002
|
|
44
|
+
IFF_NO_PI = 0x1000
|
|
45
|
+
IFF_VNET_HDR = 0x4000
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main() -> None:
|
|
49
|
+
config = json.loads(sys.argv[1])
|
|
50
|
+
fds = []
|
|
51
|
+
taps = []
|
|
52
|
+
for spec in config["interfaces"]:
|
|
53
|
+
fd = open("/dev/net/tun", "rb+", buffering=0)
|
|
54
|
+
name = spec["tap"]
|
|
55
|
+
ifr = struct.pack("16sH", name.encode(), IFF_TAP | IFF_NO_PI | IFF_VNET_HDR)
|
|
56
|
+
fcntl.ioctl(fd, TUNSETIFF, ifr)
|
|
57
|
+
subprocess.run(["ip", "link", "set", name, "address", spec["tap_mac"]], check=True)
|
|
58
|
+
subprocess.run(["ip", "link", "set", name, "master", spec["bridge"]], check=True)
|
|
59
|
+
subprocess.run(["ip", "link", "set", name, "up"], check=True)
|
|
60
|
+
fds.append(fd)
|
|
61
|
+
taps.append(name)
|
|
62
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
|
63
|
+
client.connect(config["socket"])
|
|
64
|
+
payload = json.dumps({"taps": taps}).encode()
|
|
65
|
+
rights = array.array("i", [fd.fileno() for fd in fds])
|
|
66
|
+
client.sendmsg([payload], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, rights.tobytes())])
|
|
67
|
+
ack = client.recv(1)
|
|
68
|
+
if ack != b"1":
|
|
69
|
+
raise SystemExit("handoff not acknowledged; taps die with this process")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
main()
|
|
73
|
+
'''
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AttachError(Exception):
|
|
77
|
+
"""The attachment did not complete; the message carries the helper's stderr or the handoff failure."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def tap_name(cid: int, index: int) -> str:
|
|
81
|
+
"""The external attack box's per-NIC tap device name: CID-derived like the Cloud Hypervisor scheme (`ch{cid}n{index}`), distinct prefix so a review of a live netns tells the two apart, within IFNAMSIZ."""
|
|
82
|
+
return f"ab{cid}n{index}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def tap_mac(mac: str) -> str:
|
|
86
|
+
"""The tap-device MAC for a guest MAC: first octet `fe`, the libvirt rule the boot script also applies (`up.py`), so the bridge never adopts a MAC that would leak onto the segment."""
|
|
87
|
+
return f"fe{mac[2:]}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def helper_config(guest: "BootGuest", container_socket: str) -> dict[str, Any]:
|
|
91
|
+
"""The attach helper's argv[1] config for one attacker entry: tap name, tap MAC, and bridge per interface, plus the in-container handoff socket path. Pure; every value is already shape-validated by `BootPlan`."""
|
|
92
|
+
return {
|
|
93
|
+
"socket": container_socket,
|
|
94
|
+
"interfaces": [
|
|
95
|
+
{
|
|
96
|
+
"tap": tap_name(guest.cid, index),
|
|
97
|
+
"tap_mac": tap_mac(nic.mac),
|
|
98
|
+
"bridge": nic.bridge,
|
|
99
|
+
}
|
|
100
|
+
for index, nic in enumerate(guest.interfaces)
|
|
101
|
+
],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class AttachInvocation:
|
|
107
|
+
"""Everything the attacher needs to run the helper and receive the fds; built by `up`, injectable for tests."""
|
|
108
|
+
|
|
109
|
+
argv: list[str]
|
|
110
|
+
"""The `docker compose exec ... python3 - <config-json>` command."""
|
|
111
|
+
env: dict[str, str]
|
|
112
|
+
source: str
|
|
113
|
+
"""The helper program, fed on stdin (`ATTACH_HELPER_SOURCE`)."""
|
|
114
|
+
host_socket: Path
|
|
115
|
+
"""Where the host listens for the handoff (the shared vsock dir, so the container sees the same socket)."""
|
|
116
|
+
expected_fds: int
|
|
117
|
+
timeout: float = ATTACH_TIMEOUT_S
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
Attacher = Callable[[AttachInvocation], list[int]]
|
|
121
|
+
"""Runs the helper and returns one open tap fd per attacker interface, in interface order. Injectable so `up`'s attach stage is tested without a container; the default is `run_attacher`."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def receive_fds(listener: socket.socket, expected: int, timeout: float) -> list[int]:
|
|
125
|
+
"""Accept the helper's connection and receive `expected` fds via `SCM_RIGHTS`, acking so the sender may exit.
|
|
126
|
+
|
|
127
|
+
The handoff socket sits in the shared vsock directory, which the range container mounts, so every in-container uid (the unprivileged VMMs foremost) can connect to it during the window: the accept loop therefore requires the peer to be the compose-exec root helper (`SO_PEERCRED`, the same check discipline as every exec dial: host uid 0 under rootful docker, the invoking user's uid under rootless, and never a VMM's per-guest uid) and keeps accepting until a credentialed peer or the deadline, so a hostile connector can neither hand over fds nor starve the real helper of its slot. Every blocking step is deadline-bounded (B21: the failure path is as bounded as the boot path), truncated ancillary data (`MSG_CTRUNC`) refuses, and received fds are marked non-inheritable (a provider passing them to its VMM does so explicitly, e.g. `pass_fds`).
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
AttachError: No credentialed helper connected within `timeout`, the handoff was truncated, or it carried the wrong fd count (any received fds are closed before raising; half an attachment is worse than none).
|
|
131
|
+
"""
|
|
132
|
+
deadline = time.monotonic() + timeout
|
|
133
|
+
anomaly: str | None = None
|
|
134
|
+
|
|
135
|
+
def remaining() -> float:
|
|
136
|
+
left = deadline - time.monotonic()
|
|
137
|
+
if left <= 0:
|
|
138
|
+
raise AttachError(
|
|
139
|
+
f"helper never completed the handoff within {timeout:.0f}s"
|
|
140
|
+
+ (f" (last anomaly: {anomaly})" if anomaly else "")
|
|
141
|
+
)
|
|
142
|
+
return left
|
|
143
|
+
|
|
144
|
+
while True:
|
|
145
|
+
listener.settimeout(remaining())
|
|
146
|
+
try:
|
|
147
|
+
connection, _ = listener.accept()
|
|
148
|
+
except TimeoutError as error:
|
|
149
|
+
raise AttachError(
|
|
150
|
+
f"helper never connected to the handoff socket within {timeout:.0f}s"
|
|
151
|
+
+ (f" (last anomaly: {anomaly})" if anomaly else "")
|
|
152
|
+
) from error
|
|
153
|
+
with connection:
|
|
154
|
+
if peer_uid(connection) not in (0, os.getuid()):
|
|
155
|
+
continue # not the root helper; keep the slot open for it
|
|
156
|
+
# a credentialed stray (another same-uid process probing sockets)
|
|
157
|
+
# must not abort the attach either: bound its recv so it cannot
|
|
158
|
+
# eat the window, note the anomaly, and keep awaiting the helper
|
|
159
|
+
connection.settimeout(min(remaining(), 10.0))
|
|
160
|
+
try:
|
|
161
|
+
space = socket.CMSG_SPACE(expected * array.array("i").itemsize)
|
|
162
|
+
payload, ancillary, flags, _ = connection.recvmsg(4096, space)
|
|
163
|
+
except TimeoutError:
|
|
164
|
+
anomaly = "a credentialed peer connected but sent nothing"
|
|
165
|
+
continue
|
|
166
|
+
fds: list[int] = []
|
|
167
|
+
for level, kind, data in ancillary:
|
|
168
|
+
if level == socket.SOL_SOCKET and kind == socket.SCM_RIGHTS:
|
|
169
|
+
received = array.array("i")
|
|
170
|
+
received.frombytes(
|
|
171
|
+
data[: len(data) - (len(data) % received.itemsize)]
|
|
172
|
+
)
|
|
173
|
+
fds.extend(received.tolist())
|
|
174
|
+
if flags & socket.MSG_CTRUNC or len(fds) != expected:
|
|
175
|
+
for fd in fds:
|
|
176
|
+
os.close(fd)
|
|
177
|
+
anomaly = (
|
|
178
|
+
f"a credentialed peer sent {len(fds)} fds for {expected} "
|
|
179
|
+
f"interfaces"
|
|
180
|
+
f"{' (ancillary truncated)' if flags & socket.MSG_CTRUNC else ''}:"
|
|
181
|
+
f" {payload!r}"
|
|
182
|
+
)
|
|
183
|
+
continue
|
|
184
|
+
for fd in fds:
|
|
185
|
+
os.set_inheritable(fd, False)
|
|
186
|
+
connection.sendall(b"1")
|
|
187
|
+
return fds
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def run_attacher(invocation: AttachInvocation) -> list[int]:
|
|
191
|
+
"""The default `Attacher`: bind the handoff listener, run the helper in the range container, receive the fds, and require the helper's clean exit.
|
|
192
|
+
|
|
193
|
+
The listener binds BEFORE the helper starts (the helper connects, so there is no race), world-connectable because the container's root is not this uid; the private 0700 per-user root of the vsock dir bounds who can reach the path at all, and the socket is removed as soon as the handoff completes.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
AttachError: The helper failed, timed out, or the handoff was short; the message carries the helper's stderr tail.
|
|
197
|
+
"""
|
|
198
|
+
invocation.host_socket.unlink(missing_ok=True)
|
|
199
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as listener:
|
|
200
|
+
listener.bind(str(invocation.host_socket))
|
|
201
|
+
os.chmod(invocation.host_socket, 0o666)
|
|
202
|
+
listener.listen(4)
|
|
203
|
+
process = subprocess.Popen(
|
|
204
|
+
invocation.argv,
|
|
205
|
+
stdin=subprocess.PIPE,
|
|
206
|
+
stdout=subprocess.DEVNULL,
|
|
207
|
+
stderr=subprocess.PIPE,
|
|
208
|
+
env=invocation.env,
|
|
209
|
+
)
|
|
210
|
+
delivered = False
|
|
211
|
+
try:
|
|
212
|
+
assert process.stdin is not None # Popen with stdin=PIPE guarantees it
|
|
213
|
+
# the helper reads its program to EOF before executing, so stdin
|
|
214
|
+
# closes NOW; detach it from the Popen so communicate() below does
|
|
215
|
+
# not flush the already-closed pipe
|
|
216
|
+
process.stdin.write(invocation.source.encode())
|
|
217
|
+
process.stdin.close()
|
|
218
|
+
process.stdin = None
|
|
219
|
+
try:
|
|
220
|
+
fds = receive_fds(listener, invocation.expected_fds, invocation.timeout)
|
|
221
|
+
except AttachError as error:
|
|
222
|
+
process.kill()
|
|
223
|
+
_, stderr = process.communicate(timeout=10.0)
|
|
224
|
+
raise AttachError(
|
|
225
|
+
f"{error} (helper stderr: {stderr.decode('utf-8', 'replace').strip()[-800:]})"
|
|
226
|
+
) from error
|
|
227
|
+
try:
|
|
228
|
+
_, stderr = process.communicate(timeout=invocation.timeout)
|
|
229
|
+
except subprocess.TimeoutExpired:
|
|
230
|
+
process.kill()
|
|
231
|
+
_, stderr = process.communicate(timeout=10.0)
|
|
232
|
+
if process.returncode != 0:
|
|
233
|
+
for fd in fds:
|
|
234
|
+
os.close(fd)
|
|
235
|
+
raise AttachError(
|
|
236
|
+
"helper exited "
|
|
237
|
+
f"{process.returncode}: {stderr.decode('utf-8', 'replace').strip()[-800:]}"
|
|
238
|
+
)
|
|
239
|
+
delivered = True
|
|
240
|
+
finally:
|
|
241
|
+
invocation.host_socket.unlink(missing_ok=True)
|
|
242
|
+
if not delivered and process.poll() is None:
|
|
243
|
+
# an unexpected escape (connection reset, interrupt) must not
|
|
244
|
+
# orphan the root helper un-reaped
|
|
245
|
+
process.kill()
|
|
246
|
+
try:
|
|
247
|
+
process.communicate(timeout=10.0)
|
|
248
|
+
except subprocess.TimeoutExpired:
|
|
249
|
+
pass
|
|
250
|
+
return fds
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def attachment_from_boot(
|
|
254
|
+
guest: "BootGuest",
|
|
255
|
+
*,
|
|
256
|
+
project: str,
|
|
257
|
+
fds: list[int],
|
|
258
|
+
seed_dir: Path | None,
|
|
259
|
+
) -> FootholdAttachment:
|
|
260
|
+
"""Fold one attacker `boot.json` entry plus its received tap fds into the provider's `FootholdAttachment`. Pure.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
ValueError: The entry lacks the attacker address facts (a bundle rendered before they existed) or sizing; the caller folds this into its re-render refusal.
|
|
264
|
+
"""
|
|
265
|
+
if guest.cpus is None or guest.memory_mb is None:
|
|
266
|
+
raise ValueError(f"attacker entry {guest.name!r} carries no sizing")
|
|
267
|
+
interfaces: list[AttachedInterface] = []
|
|
268
|
+
for nic, fd in zip(guest.interfaces, fds, strict=True):
|
|
269
|
+
if nic.network is None or nic.ip is None or nic.prefixlen is None:
|
|
270
|
+
raise ValueError(
|
|
271
|
+
f"attacker entry {guest.name!r} carries no address facts "
|
|
272
|
+
"(bundle rendered before attack-box support); re-render the bundle"
|
|
273
|
+
)
|
|
274
|
+
interfaces.append(
|
|
275
|
+
AttachedInterface(
|
|
276
|
+
segment=nic.network,
|
|
277
|
+
bridge=nic.bridge,
|
|
278
|
+
tap_fd=fd,
|
|
279
|
+
mac=nic.mac,
|
|
280
|
+
ip=IPv4Address(nic.ip),
|
|
281
|
+
prefixlen=nic.prefixlen,
|
|
282
|
+
gateway=IPv4Address(nic.gateway) if nic.gateway is not None else None,
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
return FootholdAttachment(
|
|
286
|
+
project=project,
|
|
287
|
+
guest_name=guest.name,
|
|
288
|
+
cid=guest.cid,
|
|
289
|
+
cpus=guest.cpus,
|
|
290
|
+
memory_mb=guest.memory_mb,
|
|
291
|
+
interfaces=tuple(interfaces),
|
|
292
|
+
seed_dir=seed_dir,
|
|
293
|
+
)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Backend selection for `INSPECT_RANGES_ATTACK_BOX`: the grammar, the bundle, and the resolver.
|
|
2
|
+
|
|
3
|
+
The variable's value IS the registration, exactly as `INSPECT_RANGES_HOST` works (`_host/backend.py`): a dotted import path naming an `AttackBoxFactory`, as `<module>.<factory>[:<config>]`, imported and called at resolution. There are no built-in forms; the variable's absence is the built-in behavior (the realizer renders and boots the attack box itself). The value splits on the FIRST colon, so a config may itself contain colons.
|
|
4
|
+
|
|
5
|
+
The value is persisted verbatim as the project's attack-box `origin` (`record.py`), and teardown-by-origin re-resolves it through `resolve_attack_box` in whatever process reclaims: `down` foremost, which may run fresh and never saw the driver's environment. Factories and provider constructors must therefore be cheap and must not dial anything: resolution happens in doctor, in `down`, and before `up` touches any docker resource; remote work belongs in the provider's `boot`.
|
|
6
|
+
|
|
7
|
+
The seam itself is deliberately minimal (every method added is API someone freezes against): an attachment goes in, an exec surface and a teardown obligation come out. The provider's exec surface is held to the daemon wire protocol over a unix socket (the Cloud Hypervisor hybrid-vsock shape, `HybridEndpoint`), which is what makes the published conformance battery, not code review, the gate a provider must pass.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import importlib
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from ipaddress import IPv4Address
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Protocol
|
|
18
|
+
|
|
19
|
+
from .._channel.vsock import HybridEndpoint
|
|
20
|
+
from .record import AttackBoxRecord
|
|
21
|
+
|
|
22
|
+
ENV_VAR = "INSPECT_RANGES_ATTACK_BOX"
|
|
23
|
+
|
|
24
|
+
DoctorProbe = Callable[[], str | None]
|
|
25
|
+
"""An optional reachability probe for `inspect-ranges doctor`: `None` when healthy, else the failure detail (the `_host/backend.py` shape, aliased here so the realizer can import this module without the host-seam dependency chain)."""
|
|
26
|
+
|
|
27
|
+
_GRAMMAR = "(expected a backend factory as '<module>.<factory>[:<config>]')"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class AttachedInterface:
|
|
32
|
+
"""One L2 attachment point on a foothold segment, built inside the range container's netns.
|
|
33
|
+
|
|
34
|
+
The tap is created and enslaved to the segment bridge inside the range netns before the provider sees it; the provider receives only the open fd. The host netns never carries a device for it, and the attachment dies with the fd (the tap is non-persistent) or with the range container's netns, whichever comes first.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
segment: str
|
|
38
|
+
"""The attached network's name in the plan."""
|
|
39
|
+
bridge: str
|
|
40
|
+
"""The segment bridge device name inside the range netns (a fact; the tap is already enslaved)."""
|
|
41
|
+
tap_fd: int
|
|
42
|
+
"""The open tap fd. The provider owns it from handoff: pass it to the VMM and close it on teardown."""
|
|
43
|
+
mac: str
|
|
44
|
+
"""The allocated guest MAC the external box MUST present on this segment (addressing and ACLs treat it as this guest)."""
|
|
45
|
+
ip: IPv4Address
|
|
46
|
+
"""The allocated interface address the external box MUST take."""
|
|
47
|
+
prefixlen: int
|
|
48
|
+
"""The segment prefix length."""
|
|
49
|
+
gateway: IPv4Address | None
|
|
50
|
+
"""The segment gateway address, when the segment has one."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class FootholdAttachment:
|
|
55
|
+
"""What the compiled plan hands an external attack-box provider: attachment in, nothing else.
|
|
56
|
+
|
|
57
|
+
Everything here is a plan fact; the provider supplies its own box (image, VMM, hardening) and attaches it behind the taps. `seed_dir` points at the rendered cloud-init seed for the attack box when the bundle carries one; a provider MAY consume it for in-guest network configuration and is never required to.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
project: str
|
|
61
|
+
guest_name: str
|
|
62
|
+
cid: int
|
|
63
|
+
"""The guest's plan-reserved control-channel CID: a pure addressing token for exec routing, never a uid or tap-name promise."""
|
|
64
|
+
cpus: int
|
|
65
|
+
memory_mb: int
|
|
66
|
+
interfaces: tuple[AttachedInterface, ...]
|
|
67
|
+
seed_dir: Path | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class AttackBoxHandle:
|
|
72
|
+
"""What a provider's `boot` returns: the exec surface plus a reclaim token, nothing else."""
|
|
73
|
+
|
|
74
|
+
exec_endpoint: HybridEndpoint
|
|
75
|
+
"""A unix socket serving the Cloud Hypervisor hybrid-vsock shape: the `CONNECT <port>` / `OK <port>` preamble, then the daemon wire protocol, one request per connection; the peer uid is enforced at every dial."""
|
|
76
|
+
reclaim_token: str = ""
|
|
77
|
+
"""Opaque provider state persisted in the attack-box record, handed back verbatim at `teardown`."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AttackBoxProvider(Protocol):
|
|
81
|
+
"""An external supplier of one range's dedicated attack box.
|
|
82
|
+
|
|
83
|
+
`boot` receives the attachment and returns the running box's handle; it owns the box's lifecycle from that moment. `teardown` must be idempotent (a crash between the record write and `boot` reclaims a box that never existed) and must tolerate a token from a `boot` that half-completed.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
async def boot(self, attachment: FootholdAttachment) -> AttackBoxHandle: ...
|
|
87
|
+
|
|
88
|
+
async def teardown(self, project: str, reclaim_token: str) -> None: ...
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class AttackBoxCapabilities:
|
|
93
|
+
"""What the backend declares it can do; a declared gap is recorded as evidence, never silently absent."""
|
|
94
|
+
|
|
95
|
+
console_stream: bool = False
|
|
96
|
+
"""Whether the provider delivers the attack box's console stream. `False` records a declared gap on `console/<guest>`; `True` is not yet supported (no ingest path) and is refused."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
AttackBoxReclaimFn = Callable[[AttackBoxRecord, Path], None]
|
|
100
|
+
"""The teardown-by-origin connector: `(record, state_dir)`; raises on failure, after which `down` refuses to touch the range's own resources (a dead netns strands a live foreign VMM)."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class AttackBoxBackend:
|
|
105
|
+
"""One resolved backend bundle: the provider plus the hooks the provider protocol alone cannot carry.
|
|
106
|
+
|
|
107
|
+
`reclaim` overrides the teardown-by-origin connector; left `None`, teardown runs `provider.teardown` with the recorded token. `doctor_probe` lets `inspect-ranges doctor` check reachability. `capabilities` is the declared-drop surface the evidence record and the conformance battery hold the provider to.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
provider: AttackBoxProvider
|
|
111
|
+
reclaim: AttackBoxReclaimFn | None = None
|
|
112
|
+
doctor_probe: DoctorProbe | None = None
|
|
113
|
+
capabilities: AttackBoxCapabilities = AttackBoxCapabilities()
|
|
114
|
+
|
|
115
|
+
def reclaimer(self) -> AttackBoxReclaimFn:
|
|
116
|
+
"""The teardown-by-origin connector `down` runs: the explicit `reclaim`, or the provider's own `teardown` with the recorded token."""
|
|
117
|
+
if self.reclaim is not None:
|
|
118
|
+
return self.reclaim
|
|
119
|
+
provider = self.provider
|
|
120
|
+
|
|
121
|
+
def provider_reclaim(record: AttackBoxRecord, state_dir: Path) -> None:
|
|
122
|
+
asyncio.run(provider.teardown(record.project, record.reclaim_token))
|
|
123
|
+
|
|
124
|
+
return provider_reclaim
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class AttackBoxContext:
|
|
129
|
+
"""What an `AttackBoxFactory` is called with."""
|
|
130
|
+
|
|
131
|
+
origin: str
|
|
132
|
+
"""The full `INSPECT_RANGES_ATTACK_BOX` value, verbatim: what `up` stamps into the attack-box record, so a fresh process re-resolves teardown from disk alone."""
|
|
133
|
+
config: str | None
|
|
134
|
+
"""The text after the value's first colon, `None` when absent: the backend's own opaque config."""
|
|
135
|
+
state_dir: Path
|
|
136
|
+
"""The shared project state dir (the attack-box record lives under it)."""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
AttackBoxFactory = Callable[[AttackBoxContext], AttackBoxBackend]
|
|
140
|
+
"""What the dotted path in `INSPECT_RANGES_ATTACK_BOX` names. Must be cheap: no dialing, no probing; remote work belongs in the provider's `boot`."""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class ParsedAttackBox:
|
|
145
|
+
"""One `INSPECT_RANGES_ATTACK_BOX` value, split but not yet resolved."""
|
|
146
|
+
|
|
147
|
+
value: str
|
|
148
|
+
"""The full value, verbatim: what `up` stamps as the record's origin."""
|
|
149
|
+
head: str
|
|
150
|
+
"""The part before the first colon: the factory's dotted import path."""
|
|
151
|
+
config: str | None
|
|
152
|
+
"""The part after the first colon (`None` when absent): opaque backend config."""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def parse_attack_box(value: str) -> ParsedAttackBox:
|
|
156
|
+
"""Split an `INSPECT_RANGES_ATTACK_BOX` value against the grammar, without importing anything.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ValueError: The value is not a dotted factory path; the message carries the full grammar.
|
|
160
|
+
"""
|
|
161
|
+
head, sep, tail = value.partition(":")
|
|
162
|
+
config = tail if sep else None
|
|
163
|
+
if "." in head and all(part.isidentifier() for part in head.split(".")):
|
|
164
|
+
return ParsedAttackBox(value, head, config)
|
|
165
|
+
raise ValueError(f"unknown {ENV_VAR} backend {value!r} {_GRAMMAR}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def resolve_attack_box(value: str, state_dir: Path) -> AttackBoxBackend:
|
|
169
|
+
"""THE resolver: an `INSPECT_RANGES_ATTACK_BOX` value (or a persisted record origin) to its backend bundle.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
ValueError: The value fits no grammar form, the dotted path does not import or does not name a callable, or the factory returned something other than an `AttackBoxBackend`. A factory's own exception propagates as-is.
|
|
173
|
+
"""
|
|
174
|
+
parsed = parse_attack_box(value)
|
|
175
|
+
module_name, _, attr = parsed.head.rpartition(".")
|
|
176
|
+
try:
|
|
177
|
+
module = importlib.import_module(module_name)
|
|
178
|
+
except ImportError as error:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"{ENV_VAR}={parsed.value!r}: backend module {module_name!r} does "
|
|
181
|
+
f"not import ({error}); install the package providing it, or fix "
|
|
182
|
+
f"the variable {_GRAMMAR}"
|
|
183
|
+
) from error
|
|
184
|
+
factory: Any = getattr(module, attr, None)
|
|
185
|
+
if factory is None:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"{ENV_VAR}={parsed.value!r}: module {module_name!r} has no "
|
|
188
|
+
f"attribute {attr!r} {_GRAMMAR}"
|
|
189
|
+
)
|
|
190
|
+
if not callable(factory):
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"{ENV_VAR}={parsed.value!r}: {parsed.head!r} is not callable "
|
|
193
|
+
"(expected an AttackBoxFactory)"
|
|
194
|
+
)
|
|
195
|
+
backend = factory(
|
|
196
|
+
AttackBoxContext(origin=parsed.value, config=parsed.config, state_dir=state_dir)
|
|
197
|
+
)
|
|
198
|
+
if not isinstance(backend, AttackBoxBackend):
|
|
199
|
+
raise ValueError(
|
|
200
|
+
f"{ENV_VAR}={parsed.value!r}: {parsed.head!r} returned "
|
|
201
|
+
f"{type(backend).__name__}, not an AttackBoxBackend"
|
|
202
|
+
)
|
|
203
|
+
return backend
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def attack_box_origin() -> str | None:
|
|
207
|
+
"""The configured attack-box origin, validated against the grammar; `None` when unset or empty.
|
|
208
|
+
|
|
209
|
+
Validation here is parse-only (no import), so a malformed value refuses loudly wherever the origin is first consulted, before any resource exists.
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
ValueError: The variable is set but fits no grammar form.
|
|
213
|
+
"""
|
|
214
|
+
value = os.environ.get(ENV_VAR)
|
|
215
|
+
if not value:
|
|
216
|
+
return None
|
|
217
|
+
parse_attack_box(value)
|
|
218
|
+
return value
|