Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 109 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2715,11 +2715,57 @@ jobs:
[ "$rc" -eq 2 ] || { echo "FAIL: bare usage must exit 2 (never clean), got $rc"; exit 1; }
echo "$out" | grep -q "RETAINED (root path shown) | OBSERVED_ONLY" || { echo "FAIL: usage must document the verdict vocabulary"; exit 1; }
set +e
out=$(dotnet "$GITHUB_WORKSPACE"/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll roots --pid 999999 --type X 2>&1)
out=$(dotnet "$GITHUB_WORKSPACE"/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll roots --pid 999999 --type X \
--out "$RUNNER_TEMP/nopid.json" 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 2 ] || { echo "FAIL: a failed attach must exit 2, never read as clean, got $rc"; exit 1; }
# Every platform, no target needed: a run that could not look records
# that it could not look. Absence of a file is not that statement —
# it is also "never invoked" and "artifact lost", so it means nothing.
cat "$RUNNER_TEMP/nopid.json"
python3 - "$RUNNER_TEMP/nopid.json" unreadable-target <<'PY'
import json, sys
doc = json.load(open(sys.argv[1], encoding="utf-8"))
ex = doc.get("execution") or {}
problems = []
if ex.get("state") != "not_evaluated":
problems.append(f"execution.state {ex.get('state')!r}, want 'not_evaluated'")
if (ex.get("reason") or {}).get("code") != sys.argv[2]:
problems.append(f"reason.code {(ex.get('reason') or {}).get('code')!r}, want {sys.argv[2]!r}")
if not (ex.get("reason") or {}).get("detail"):
problems.append("a not_evaluated record must carry a reason detail")
for key in ("verdict", "retained"):
if key in doc:
problems.append(f"a run that did not look must not record {key!r}")
for p in problems:
print(f"FAIL: {p}", file=sys.stderr)
sys.exit(1 if problems else 0)
PY
# A usage error is equally a state the record has to carry.
set +e
dotnet "$GITHUB_WORKSPACE"/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll roots \
--out "$RUNNER_TEMP/usage.json" > /dev/null 2>&1
rc=$?
set -e
[ "$rc" -eq 2 ] || { echo "FAIL: a usage error must exit 2, got $rc"; exit 1; }
python3 - "$RUNNER_TEMP/usage.json" usage-error <<'PY'
import json, sys
doc = json.load(open(sys.argv[1], encoding="utf-8"))
ex = doc.get("execution") or {}
problems = []
if ex.get("state") != "not_evaluated":
problems.append(f"execution.state {ex.get('state')!r}, want 'not_evaluated'")
if (ex.get("reason") or {}).get("code") != sys.argv[2]:
problems.append(f"reason.code {(ex.get('reason') or {}).get('code')!r}, want {sys.argv[2]!r}")
for key in ("verdict", "retained"):
if key in doc:
problems.append(f"a run that did not look must not record {key!r}")
for p in problems:
print(f"FAIL: {p}", file=sys.stderr)
sys.exit(1 if problems else 0)
PY
dotnet "$GITHUB_WORKSPACE"/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll selftest
- name: "WPF flagship on Windows: the witness names the window the hub is holding (A2/A3)"
if: runner.os == 'Windows'
Expand Down Expand Up @@ -2860,9 +2906,68 @@ jobs:
|| { echo "FAIL: the diagnostic must name the policy that refused"; exit 1; }
echo "$out" | grep -q "NOT a verdict" \
|| { echo "FAIL: the diagnostic must say it did not look"; exit 1; }
[ ! -s "$RUNNER_TEMP/denied.json" ] \
|| { echo "FAIL: a refused attach must not write a verdict artifact"; exit 1; }
echo "OK: denied attach -> exit 2, policy named, no artifact written"
# The exit code says "I did not look" for as long as the process lives;
# the record has to say it afterwards. An absent file cannot: it also
# means never invoked, runner died, or artifact lost in transit. So the
# refusal is RECORDED — while the verdict it never earned is not.
[ -s "$RUNNER_TEMP/denied.json" ] \
|| { echo "FAIL: a refused attach must still record that it did not look"; exit 1; }
cat "$RUNNER_TEMP/denied.json"
python3 - "$RUNNER_TEMP/denied.json" <<'PY'
import json, sys
doc = json.load(open(sys.argv[1], encoding="utf-8"))
ex = doc.get("execution") or {}
problems = []
if ex.get("state") != "not_evaluated":
problems.append(f"execution.state {ex.get('state')!r}, want 'not_evaluated'")
reason = ex.get("reason") or {}
if reason.get("code") != "refused-attach":
problems.append(f"reason.code {reason.get('code')!r}, want 'refused-attach'")
# A permission claim belongs to the one stage a permission check applies
# to. Anything later opened the target fine and must not cite a policy.
if reason.get("stage") != "open-target":
problems.append(f"reason.stage {reason.get('stage')!r}, want 'open-target'")
if "ptrace_scope" not in str(reason.get("policy_in_force", "")):
problems.append("reason.policy_in_force must name the policy that was in force, "
f"got {reason.get('policy_in_force')!r}")
# The half that must NOT come back: an unearned verdict, or an empty
# `retained` that reads downstream as "looked, found nothing".
for key in ("verdict", "retained"):
if key in doc:
problems.append(f"a refused attach must not record {key!r} (got {doc[key]!r})")
for p in problems:
print(f"FAIL: {p}", file=sys.stderr)
sys.exit(1 if problems else 0)
PY
# The other half, with the SAME restricting policy still in force: a
# failure that is not an attach must not borrow it. Yama being on is
# observable; Yama having caused the failure in hand is not, and the
# record may only say the first.
echo "not a dump" > "$RUNNER_TEMP/not-a-dump"
set +e
dotnet "$WITNESS" roots --dump "$RUNNER_TEMP/not-a-dump" --type X \
--out "$RUNNER_TEMP/dumpfail.json" > "$RUNNER_TEMP/dumpfail.log" 2>&1
drc=$?
set -e
cat "$RUNNER_TEMP/dumpfail.log"
[ "$drc" -eq 2 ] || { echo "FAIL: an unreadable dump must exit 2, got $drc"; exit 1; }
grep -q "ptrace_scope" "$RUNNER_TEMP/dumpfail.log" \
&& { echo "FAIL: a dump read must not lecture about ptrace"; exit 1; }
python3 - "$RUNNER_TEMP/dumpfail.json" <<'PY'
import json, sys
reason = ((json.load(open(sys.argv[1], encoding="utf-8")).get("execution") or {})
.get("reason") or {})
problems = []
if reason.get("code") != "unreadable-target":
problems.append(f"reason.code {reason.get('code')!r}, want 'unreadable-target'")
if "policy_in_force" in reason:
problems.append(f"a dump read cited a ptrace policy: {reason['policy_in_force']!r}")
for p in problems:
print(f"FAIL: {p}", file=sys.stderr)
sys.exit(1 if problems else 0)
PY
echo "OK: denied attach -> exit 2, policy named, refusal recorded, no verdict;"
echo "OK: a non-attach failure under the same policy does not borrow it"
- name: "flagship demo orchestrator end-to-end: bad DEMONSTRATED, ok VERIFIED (A3/A4)"
if: runner.os == 'Linux'
run: |
Expand Down
21 changes: 16 additions & 5 deletions audit/runtime/RetentionPath/Heap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,24 @@ internal sealed class RetentionWalker : IDisposable
private readonly DataTarget _target;
private readonly ClrRuntime _runtime;

/// <summary>Attach to a LIVE process (suspends it for the read). No procdump needed.</summary>
public static RetentionWalker AttachToProcess(int pid) =>
new RetentionWalker(DataTarget.AttachToProcess(pid, suspend: true));
/// <summary>Attach to a LIVE process (suspends it for the read). No procdump needed.
///
/// Opening the target is deliberately its OWN step, separate from
/// <see cref="Create"/>: it is the only one a kernel ptrace policy can
/// refuse. Folding the two together makes every CLR-initialisation
/// failure indistinguishable from a permission failure, and a caller
/// that cannot tell them apart will attribute one to the other.</summary>
public static DataTarget OpenLiveTarget(int pid) =>
DataTarget.AttachToProcess(pid, suspend: true);

/// <summary>Read a full dump — the right choice when the target must not be paused.</summary>
public static RetentionWalker LoadDump(string path) =>
new RetentionWalker(DataTarget.LoadDump(path));
public static DataTarget OpenDumpTarget(string path) =>
DataTarget.LoadDump(path);

/// <summary>Build the CLR view over an already-opened target. Takes
/// ownership: on success the walker disposes the target, and on failure
/// the target is still the caller's to dispose.</summary>
public static RetentionWalker Create(DataTarget target) => new RetentionWalker(target);

private RetentionWalker(DataTarget target)
{
Expand Down
Loading
Loading