Skip to content
Merged
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
131 changes: 131 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1984,6 +1984,22 @@ jobs:
echo "FAIL: a tree with leaks should exit non-zero under --fail-on-finding"; exit 1
fi
echo "OK: --fail-on-finding surfaced the leaks as a non-zero exit"
- name: A broken stage 1 is a hard error, never the "findings" tier
run: |
# Exit 1 means "analysed, and there are findings". If a failed
# extractor build could also exit 1, then a caller that chooses NOT to
# gate on findings — which is now the Action's default — reads a run
# that analysed nothing as clean. So stage-1 failures are normalised
# into the >=2 tier, and this pins it: --root at a path with no
# extractor project makes `dotnet run` fail with 1.
set +e
scripts/own-check.sh --root "$RUNNER_TEMP/no-such-root" --format github \
-- "$RUNNER_TEMP/owen-action-clean" >/dev/null 2>"$RUNNER_TEMP/stage1.err"
rc=$?
set -e
tail -3 "$RUNNER_TEMP/stage1.err" || true
[ "$rc" -ge 2 ] || { echo "FAIL: a broken stage 1 must exit >=2 (the tool did not look), got $rc"; exit 1; }
echo "OK: stage-1 failure landed in the hard-error tier (exit $rc)"
- name: SARIF surface is a valid 2.1.0 log (the structure code scanning enforces)
run: |
# The contract GitHub's code-scanning ingest enforces, checked locally so
Expand Down Expand Up @@ -2011,13 +2027,89 @@ jobs:
' "$out" >/dev/null \
|| { echo "FAIL: a result is unlocated or references an undeclared rule"; exit 1; }
echo "OK: SARIF 2.1.0 — Owen driver, every result rule-backed + located"
- name: Fixtures for the action's status contract (clean tree, broken config)
run: |
# Tier 0: a tree with nothing to report.
mkdir -p "$RUNNER_TEMP/owen-action-clean"
printf 'public class Clean { public int M() { return 1; } }\n' \
> "$RUNNER_TEMP/owen-action-clean/Clean.cs"
# Asserted here rather than on the action step: a `uses:` step's
# stdout is not capturable, so "annotates nothing" is checked through
# the same script the action runs.
out=$(scripts/own-check.sh --format github -- "$RUNNER_TEMP/owen-action-clean")
[ -z "$(echo "$out" | grep '^::' || true)" ] \
|| { echo "FAIL: a clean tree must emit no annotations, got:"; echo "$out"; exit 1; }
echo "OK: clean tree, zero annotations"
# Tier >=2: a config own-check refuses to parse, so it exits 2 before
# analysing anything.
printf 'this is not = valid toml [[[\n' > "$RUNNER_TEMP/owen-broken.toml"
- name: The composite action runs end-to-end (non-failing)
uses: ./
with:
path: frontend/roslyn/samples
format: github
fail-on-finding: "false"

# The four tiers of the action's status contract, each pinned. The point
# of "annotations, not failures" is that it applies to FINDINGS ONLY;
# every other tier must keep behaving exactly as before, or the friendly
# default silently turns "the tool could not run" into a green check.
#
# Deliberately here and not only in action-marketplace-readiness.yml,
# which covers the same ground for the consumer fixture: that workflow is
# PATH-FILTERED (action.yml, own-check.sh, …), so a change to the Python
# core's exit codes — where these tiers actually originate — would never
# wake it. This job runs on every push and PR.
- name: "Tier 1 — findings, DEFAULT inputs: annotations published, step succeeds"
id: default_findings
uses: ./
with:
path: frontend/roslyn/samples
format: github
# fail-on-finding deliberately NOT set: this is the out-of-the-box
# experience of someone who just added Owen to their repository.
- name: "Tier 1 — the default really was non-blocking"
run: |
[ "${{ steps.default_findings.outcome }}" = "success" ] \
|| { echo "FAIL: a leaky tree must not fail the step by default"; exit 1; }
echo "OK: findings did not fail the step under default inputs"
- name: "Tier 1 opt-in — fail-on-finding: true turns the same findings into a failure"
id: strict_findings
continue-on-error: true
uses: ./
with:
path: frontend/roslyn/samples
format: github
fail-on-finding: "true"
- name: "Tier 1 opt-in — the strict mode really did fail"
run: |
[ "${{ steps.strict_findings.outcome }}" = "failure" ] \
|| { echo "FAIL: fail-on-finding: true must fail on a leaky tree (outcome=${{ steps.strict_findings.outcome }})"; exit 1; }
echo "OK: the same tree, the same annotations, a failing status"
- name: "Tier 0 — a clean tree succeeds and annotates nothing"
id: clean_tree
uses: ./
with:
path: ${{ runner.temp }}/owen-action-clean
format: github
- name: "Tier >=2 — an operational failure fails the step even with fail-on-finding: false"
id: operational_failure
continue-on-error: true
uses: ./
with:
path: frontend/roslyn/samples
format: github
fail-on-finding: "false"
# A malformed own.toml makes own-check exit 2 before it can analyse
# anything. That is the tool failing to LOOK, not a defect found in
# the caller's code — the friendly default must not absorb it.
config: ${{ runner.temp }}/owen-broken.toml
- name: "Tier >=2 — the operational failure really did fail"
run: |
[ "${{ steps.operational_failure.outcome }}" = "failure" ] \
|| { echo "FAIL: an operational failure (exit >=2) must fail the step regardless of fail-on-finding (outcome=${{ steps.operational_failure.outcome }})"; exit 1; }
echo "OK: 'could not look' did not become 'looked and found nothing'"

# Dog-food the code-scanning surface end-to-end (P-013): run the composite action
# with format: sarif over the sample tree, then upload the log to GitHub code
# scanning. The repo is public, so code scanning is free — this is the live proof
Expand Down Expand Up @@ -2569,6 +2661,45 @@ jobs:
print(f"ok: nothing durably retains the window — verdict "
f"{doc.get('verdict')}, roots seen: {dict(kinds) or 'none'}")
PY
- name: "attach denied by kernel policy is an honest exit 2, never a clean verdict (A4)"
if: runner.os == 'Linux'
run: |
# The operational contract, asserted rather than described: with Yama
# at its default scope, attaching to a NON-DESCENDANT process is
# refused by the kernel. The witness must say so, name the policy, and
# exit 2 — the tier that means "I did not look", distinct from 0
# (looked, nothing retained) and 1 (looked, retention found). This is
# the failure the first CI round of this arc actually hit.
#
# Runs BEFORE the demo step relaxes the scope; the app is a sibling of
# the witness (both children of this shell), which is exactly the
# shape Yama scope 1 forbids.
sudo sysctl -w kernel.yama.ptrace_scope=1
dotnet build "$GITHUB_WORKSPACE/examples/flagship/console/bad" -c Release -v quiet
APP="$GITHUB_WORKSPACE/examples/flagship/console/bad/bin/Release/net8.0/BadDocumentApp.dll"
WITNESS="$GITHUB_WORKSPACE/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll"
STOP="$RUNNER_TEMP/denied-stop"; LOG="$RUNNER_TEMP/denied-app.log"
rm -f "$STOP"
OWEN_FLAGSHIP_HOLD=1 OWEN_FLAGSHIP_STOP="$STOP" OWEN_FLAGSHIP_HOLD_SECONDS=120 \
dotnet "$APP" > "$LOG" 2>&1 &
for _ in $(seq 1 60); do grep -q "holding (pid" "$LOG" 2>/dev/null && break; sleep 1; done
grep -q "holding (pid" "$LOG" || { cat "$LOG"; echo "FAIL: sample never held"; exit 1; }
PID=$(sed -n 's/.*holding (pid \([0-9]*\)).*/\1/p' "$LOG" | head -1)
set +e
out=$(dotnet "$WITNESS" roots --pid "$PID" --type Owen.Flagship.DocumentView \
--out "$RUNNER_TEMP/denied.json" 2>&1)
rc=$?
set -e
touch "$STOP"
echo "$out"
[ "$rc" -eq 2 ] || { echo "FAIL: a denied attach must exit 2, got $rc"; exit 1; }
echo "$out" | grep -q "ptrace_scope" \
|| { 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"
- name: "flagship demo orchestrator end-to-end: bad DEMONSTRATED, ok VERIFIED (A3/A4)"
if: runner.os == 'Linux'
run: |
Expand Down
67 changes: 63 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,82 @@

# Own.NET

> Own.NET finds lifetime/resource bugs that C# cannot express: WPF/event
> leaks, missing `Dispose`, DI lifetime mismatch, and pooled-buffer misuse.
> **Owen finds .NET lifetime bugs and confirms them at runtime — showing the
> reference path that keeps the object alive.**

*Find leaks before the profiler.* GC collects unreachable objects; Own finds
objects that should have become unreachable. `event +=` is acquire, `-=` is
release.
release. It also finds missing `Dispose`, DI lifetime mismatch, and
pooled-buffer misuse — the same ownership question in different skins.

## Both halves, on one sample

**The defect, at the line that causes it.** A window subscribes to a
process-lifetime settings hub; the unsubscribe exists, but the close path
never reaches it:

```console
$ owen check examples/flagship/wpf/bad --fail-on-finding
DocumentWindow.xaml.cs:28: warning: [OWN001] event '_settings.PropertyChanged'
is subscribed (handler 'OnSettingsChanged') but never unsubscribed; its
source is an injected dependency whose lifetime is unknown, so it may
outlive and keep 'DocumentWindow' alive (possible leak)
```

**The same object at runtime, and what is actually holding it.** A separate
step — you run it when you want proof, not on every build:

```text
verdict: RETAINED — DocumentWindow: 200 on the heap, 200 durably retained

AppSettings
→ PropertyChanged
→ _invocationList
→ handler
→ DocumentWindow

100% of them hang off ONE reference.
```

Both halves are held to a contract on every CI run, on Linux and Windows:

```text
bad → RETAINED / exit 1
ok → ABSENT or OBSERVED_ONLY / exit 0 / zero durable roots
```

The sample is real and runnable: [`examples/flagship/`](examples/flagship/)
(console and WPF, each a `bad`/`ok` pair whose only difference is where the
`-=` lives). What the runtime witness will and will not claim — and why
"reachable" is not "leaked" — is
[`docs/how-owen-proves-retention.md`](docs/how-owen-proves-retention.md).

## Run it in CI — 6 lines

```yaml
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: PhysShell/Own.NET@main # pre-release: no tagged release yet — pin a commit SHA for reproducibility
with:
path: .
format: github # inline PR annotations; use "sarif" for the Security tab
fail-on-finding: "true"
```

Findings arrive as PR annotations and **the step stays green** — adding Owen
to a repository does not turn its CI red on day one. When you are ready to
gate on it:

```yaml
with:
path: .
fail-on-finding: true
```

Both modes analyse identically and publish identical annotations/SARIF; only
the step's final status differs. And `fail-on-finding` governs *findings*
only — if Owen cannot complete the analysis (crash, unreadable input, no SARIF
written) the step fails in either mode. A friendly default must never turn
"could not look" into "looked and found nothing".

Once a release ships, prefer a pinned tag (`@v0.1.0`) or the moving major tag
(`@v0`) over `@main` — see
[`docs/notes/action-marketplace-readiness.md`](docs/notes/action-marketplace-readiness.md)
Expand Down
70 changes: 65 additions & 5 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,84 @@

# Own.NET

> Own.NET находит баги времени жизни/ресурсов, которые C# не может выразить:
> WPF/event-лики, забытый `Dispose`, рассинхрон DI lifetime и неправильное
> использование pooled-буферов.
> **Owen находит ошибки времени жизни в .NET и подтверждает их во время
> выполнения — показывая ссылочный путь, который держит объект живым.**

*Находи лики до профайлера.* GC собирает недостижимые объекты; Own находит
объекты, которые должны были стать недостижимыми. `event +=` — это acquire,
`-=` — это release.
`-=` — это release. Он также находит забытый `Dispose`, рассинхрон DI
lifetime и неправильное использование pooled-буферов — тот же вопрос о
владении в разных обличьях.

## Обе половины, на одном образце

**Дефект — на строке, которая его порождает.** Окно подписывается на хаб
настроек, живущий всё время процесса; отписка существует, но путь закрытия до
неё не доходит:

```console
$ owen check examples/flagship/wpf/bad --fail-on-finding
DocumentWindow.xaml.cs:28: warning: [OWN001] event '_settings.PropertyChanged'
is subscribed (handler 'OnSettingsChanged') but never unsubscribed; its
source is an injected dependency whose lifetime is unknown, so it may
outlive and keep 'DocumentWindow' alive (possible leak)
```

**Тот же объект во время выполнения — и то, что его действительно держит.**
Отдельный шаг: он запускается, когда нужно доказательство, а не на каждой
сборке:

```text
verdict: RETAINED — DocumentWindow: 200 on the heap, 200 durably retained

AppSettings
→ PropertyChanged
→ _invocationList
→ handler
→ DocumentWindow

100% из них висят на ОДНОЙ ссылке.
```

Обе половины держатся контракта на каждом прогоне CI, под Linux и Windows:

```text
bad → RETAINED / exit 1
ok → ABSENT or OBSERVED_ONLY / exit 0 / zero durable roots
```

Образец настоящий и запускаемый: [`examples/flagship/`](examples/flagship/)
(консоль и WPF, каждый — пара `bad`/`ok`, отличающаяся только тем, где живёт
`-=`). Что свидетель времени выполнения утверждает, а что — нет, и почему
«достижим» не значит «утёк»:
[`docs/how-owen-proves-retention.md`](docs/how-owen-proves-retention.md).

## Запустить в CI — 6 строк

```yaml
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: PhysShell/Own.NET@main # пре-релиз: тегов ещё нет — для воспроизводимости пиньте commit SHA
with:
path: .
format: github # инлайн-аннотации в PR; "sarif" — для вкладки Security
fail-on-finding: "true"
```

Находки приходят аннотациями в PR, а шаг **остаётся зелёным** — добавление
Owen в репозиторий не красит чужой CI в первый же день. Когда готовы на нём
гейтить:

```yaml
with:
path: .
fail-on-finding: true
```

Оба режима анализируют одинаково и публикуют одинаковые аннотации/SARIF;
отличается только итоговый статус шага. И `fail-on-finding` управляет только
*находками*: если Owen не смог довести анализ до конца (краш, нечитаемый вход,
SARIF не записан), шаг падает в любом режиме. Дружелюбный дефолт не имеет
права превращать «не смог посмотреть» в «посмотрел и ничего не нашёл».

После первого релиза предпочитайте закреплённый тег (`@v0.1.0`) или
подвижный major-тег (`@v0`) вместо `@main` — политика версионирования в
[`docs/notes/action-marketplace-readiness.md`](docs/notes/action-marketplace-readiness.md).
Expand Down
35 changes: 29 additions & 6 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,15 @@ inputs:
required: false
default: "error"
fail-on-finding:
description: "Fail the step when any leak is found."
description: >-
Whether a FINDING fails the step. Default false: findings are published
as annotations/SARIF and the step succeeds, so adding Owen to a repository
cannot turn its CI red on day one. Set true once you are ready to gate on
it. This input governs findings ONLY — an operational failure (the
analyser crashed, the input could not be read, no SARIF could be written)
always fails the step, in either mode.
required: false
default: "true"
default: "false"
Comment thread
PhysShell marked this conversation as resolved.
python-version:
description: "Python version for the Owen core."
required: false
Expand Down Expand Up @@ -130,8 +136,25 @@ runs:
fi
exit 0
fi
args=(--root "${{ github.action_path }}" --format "$OWN_FORMAT" --severity "$OWN_SEVERITY" "${config_args[@]}")
if [ "$OWN_FAIL_ON_FINDING" = "true" ]; then
args+=(--fail-on-finding)
# Always ask own-check for its TRUE tier (0 clean / 1 findings / >=2
# operational failure) and decide the step's status here. Run WITHOUT
# --fail-on-finding and the script folds findings into 0, making tier 1
# indistinguishable from tier 0 — and tier 1 is the only one this
# action is allowed to negotiate about.
set +e
"$check" --root "${{ github.action_path }}" --format "$OWN_FORMAT" \
--severity "$OWN_SEVERITY" "${config_args[@]}" --fail-on-finding -- "$OWN_PATH"
rc=$?
set -e
if [ "$rc" -ge 2 ]; then
Comment thread
PhysShell marked this conversation as resolved.
# NOT a finding: the analyser crashed, the input could not be read, or
# the contract drifted. "Annotations instead of failure" is a policy
# about defects found in your code, never about the tool failing to
# look — that must not reach anyone as a green check.
echo "::error::Owen could not complete the analysis (exit $rc). This is an operational failure, not a finding — fail-on-finding does not apply to it. The diagnostic is above."
exit "$rc"
fi
if [ "$rc" -eq 1 ] && [ "$OWN_FAIL_ON_FINDING" = "true" ]; then
exit 1
fi
"$check" "${args[@]}" -- "$OWN_PATH"
exit 0
Loading
Loading