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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
PYTHONPATH=fix python3 fix/tests/test_ai_fix.py
PYTHONPATH=fix python3 fix/tests/test_own_fix.py
PYTHONPATH=fix python3 fix/tests/test_orchestrate.py
PYTHONPATH=. python3 identity/tests/test_pattern.py
Comment thread
coderabbitai[bot] marked this conversation as resolved.
PYTHONPATH=. python3 report/tests/test_sarif.py
PYTHONPATH=. python3 report/tests/test_baseline.py
PYTHONPATH=. python3 report/tests/test_rules_map.py
Expand All @@ -53,6 +54,7 @@ jobs:
PYTHONPATH=fix python3 -O fix/tests/test_ai_fix.py
PYTHONPATH=fix python3 -O fix/tests/test_own_fix.py
PYTHONPATH=fix python3 -O fix/tests/test_orchestrate.py
PYTHONPATH=. python3 -O identity/tests/test_pattern.py
PYTHONPATH=. python3 -O report/tests/test_sarif.py
PYTHONPATH=. python3 -O report/tests/test_baseline.py
PYTHONPATH=. python3 -O report/tests/test_rules_map.py
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ This file provides guidance to agents when working with code in this repository.
- **OwnAudit is the canonical audit repo** (since 2026-07-18): the lift-out from `Own.NET/audit/` happened and the direction reversed — `Own.NET/audit/` is deprecated and Own.NET stays a pure SAST engine that emits findings. New aggregation/reporting/runtime/fix work lands here, not there. (Older docs in both repos may still state the pre-reversal boundary; `docs/collector-plan.md` is the current word.)
- `src/OwnAudit.Cli` and `src/OwnAudit.Runtime` intentionally target `net8.0-windows` because runtime work depends on FlaUI/ClrMD; Linux work here is mainly the Python tooling/tests. `OwnAudit.Runtime` is the home of the ClrMD heap collector (`docs/collector-plan.md`).
- Python tests are designed to run as bare scripts because `pytest` is not guaranteed in the dev shell: `PYTHONUTF8=1 python3 arch/tests/test_arch.py` or any `*/tests/test_*.py` file directly.
- **Finding identity is a frozen contract, not a helper.** `identity/pattern.py` implements `finding-pattern/v1` and `contracts/finding-pattern-v1.json` holds its canonical vectors; `viz/apply_verdicts.finding_id` is an alias for it. Every stored `fp-verdicts.json` overlay is keyed by those exact bytes, so changing the algorithm, separator, truncation or field order orphans the verdicts — and the result is an *empty report*, not an error. Do not "clean up" the hash or normalize paths/messages. The SARIF `partialFingerprints["ownAudit/v1"]` is a **different, legacy GitHub-correlation key** (normalized message, full digest, `/ordinal` suffix) and must never be used as an identity.
- Full Linux-safe regression sweep: `PYTHONUTF8=1 python3 arch/tests/test_arch.py && PYTHONUTF8=1 python3 fix/tests/test_orchestrate.py && PYTHONUTF8=1 python3 fix/tests/test_own_fix.py && PYTHONUTF8=1 python3 fix/tests/test_ai_fix.py && PYTHONUTF8=1 python3 leakmine/tests/test_leakmine.py && PYTHONUTF8=1 python3 report/tests/test_baseline.py && PYTHONUTF8=1 python3 report/tests/test_sarif.py && PYTHONUTF8=1 python3 report/tests/test_rules_map.py && PYTHONUTF8=1 python3 report/tests/test_annotate.py && PYTHONUTF8=1 python3 report/tests/test_exec.py && PYTHONUTF8=1 python3 viz/tests/test_dashboard_rules.py && PYTHONUTF8=1 python3 runtime/tests/test_runtime.py && PYTHONUTF8=1 python3 runtime/tests/test_member_aware.py && PYTHONUTF8=1 python3 oracle/fixtures/test_oracle_arch.py && PYTHONUTF8=1 python3 oracle/fixtures/test_oracle_runtime.py`.
- To run the collector against real STS documents, build the shipping stand first — `docs/sts-stand-build.md` (worktree build into `Setup`, `SERIALIZERSIM_SETUP`, `leaktest --hold`).
- `Run-Audit.ps1` is Windows-stand oriented and drives external Own.NET worktrees; it sets `PYTHONUTF8=1`, emits SARIF into `artifacts/`, and clusters optional Infer#/Roslyn results only if their SARIF files already exist.
Expand Down
98 changes: 98 additions & 0 deletions contracts/finding-pattern-v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
{
"contract": "finding-pattern/v1",
"owner": "OwnAudit (domain)",
"issue": "PhysShell/Own.NET#266",
"recipe": "sha1(path + \"\\u001f\" + rule + \"\\u001f\" + message).hexdigest()[:16]",
"id_length": 16,
"encoding": "utf-8",
"separator": "\u001f",
"notes": [
"These vectors are the executable half of the contract. A change to any expected id is a BREAKING change: every stored fp-verdicts.json overlay is keyed by these bytes, and an overlay that stops matching produces an empty report, not an error.",
"Fields are joined VERBATIM. No case folding, no path-separator rewriting, no digit or whitespace collapsing. The vectors below pin each of those individually so that a well-meant normalization fails a test instead of silently re-keying every stored verdict.",
"`line` is deliberately absent from the recipe: it drifts. Two physical findings sharing (path, rule, message) share a pattern_id on purpose — one judged verdict covers the repeated pattern. Distinguishing the physical occurrences is occurrence_id's job, not this one.",
"NOT the same value as report/sarif.py's partialFingerprints['ownAudit/v1'], which normalizes the message, joins with newlines, keeps the full digest and appends an ordinal for repeats. That one is a legacy GitHub-correlation key; identity must not depend on presentation order."
],
"vectors": [
{
"name": "windows-style-path",
"why": "a producer that emitted backslashes keeps them; the separator is part of the identity",
"path": "src\\App\\DocumentWindow.xaml.cs",
"rule": "OWN001",
"message": "event 'AppSettings.PropertyChanged' subscribed, handler never released",
"pattern_id": "9323408c303bd2cf"
},
{
"name": "posix-style-path",
"why": "the same file spelled with forward slashes is a DIFFERENT id — identity follows the emitted record, not a guess about the filesystem",
"path": "src/App/DocumentWindow.xaml.cs",
"rule": "OWN001",
"message": "event 'AppSettings.PropertyChanged' subscribed, handler never released",
"pattern_id": "bf5dae4e2ebff293"
},
{
"name": "path-case-upper",
"why": "no case folding, even though Windows filesystems are case-insensitive",
"path": "Src/App/DocumentWindow.xaml.cs",
"rule": "OWN001",
"message": "event 'AppSettings.PropertyChanged' subscribed, handler never released",
"pattern_id": "8125b290fa225510"
},
{
"name": "unicode-path-and-message",
"why": "non-ASCII paths and messages hash as UTF-8, not as escapes or a lossy encoding",
"path": "src/Документы/Окно.cs",
"rule": "OWN001",
"message": "событие 'Настройки.Изменено' подписано, обработчик не освобождён",
"pattern_id": "99ecde19649f7ae8"
},
{
"name": "message-with-digits",
"why": "digits are NOT collapsed (the SARIF fingerprint does collapse them; this contract must not)",
"path": "src/Pool.cs",
"rule": "OWN003",
"message": "buffer of 4096 bytes rented but never returned",
"pattern_id": "f98bf75335d7458d"
},
{
"name": "message-with-other-digits",
"why": "same sentence, different number: a different pattern, therefore a different id",
"path": "src/Pool.cs",
"rule": "OWN003",
"message": "buffer of 8192 bytes rented but never returned",
"pattern_id": "dbeece167c805d43"
},
{
"name": "whitespace-differs",
"why": "whitespace is NOT collapsed either — the message is taken as emitted",
"path": "src/Pool.cs",
"rule": "OWN003",
"message": "buffer of 4096 bytes rented but never returned",
"pattern_id": "58ad38785c931022"
},
{
"name": "repeated-identical",
"why": "a second physical finding with identical (path, rule, message) — the intentional collision one verdict is meant to cover",
"path": "src/App/DocumentWindow.xaml.cs",
"rule": "OWN001",
"message": "event 'AppSettings.PropertyChanged' subscribed, handler never released",
"pattern_id": "bf5dae4e2ebff293"
},
{
"name": "empty-message",
"why": "an empty field is hashed as empty rather than rejected; only a MISSING key is an error",
"path": "src/Empty.cs",
"rule": "OWN050",
"message": "",
"pattern_id": "ab023777f5fe8ea7"
}
],
"same_pattern": [
["posix-style-path", "repeated-identical"]
],
"distinct_patterns": [
["windows-style-path", "posix-style-path"],
["posix-style-path", "path-case-upper"],
["message-with-digits", "message-with-other-digits"],
["message-with-digits", "whitespace-differs"]
]
}
35 changes: 35 additions & 0 deletions docs/fp-judge/verdict-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,41 @@ identical. So a `finding_id` may map to **≥1 physical findings**:

No ordinal / tiebreaker — that would reintroduce line-order fragility for zero gain.

### Where the recipe lives now (`finding-pattern/v1`)

The implementation is [`identity/pattern.py`](../../identity/pattern.py) and the
canonical vectors are
[`contracts/finding-pattern-v1.json`](../../contracts/finding-pattern-v1.json).
`viz/apply_verdicts.finding_id` imports it and survives as the overlay-facing
alias; the bytes are unchanged, which is the point — every stored
`fp-verdicts.json` is keyed by them, and an overlay that stops matching
produces an **empty report rather than an error**. That failure is silent, so
it is pinned by a test (`identity/tests/test_pattern.py`), not by a comment.

The vectors pin what is deliberately *not* normalized: path separator, path
case, digits and whitespace in the message. A future cleanup that folds any of
them fails a test instead of quietly re-keying every stored verdict.

### `ownAudit/v1` in SARIF is a different value

`report/sarif.py` emits `partialFingerprints["ownAudit/v1"]`. It is **not**
`finding-pattern/v1` and must not be used as one:

| | `finding-pattern/v1` | SARIF `ownAudit/v1` |
|---|---|---|
| basis | `path \x1f rule \x1f message` | `rule \n path \n normalize(message)` |
| message | verbatim | lower-cased, digits → `#`, spaces collapsed |
| digest | SHA-1, first 16 hex | SHA-1, full |
| repeats | share an id (intentional) | disambiguated with a `/ordinal` suffix |

`ownAudit/v1` is a **legacy GitHub-correlation key**: it keeps code-scanning
alerts stitched together across commits. Its ordinal suffix alone disqualifies
it as an identity — the value would depend on the order results happen to be
emitted in, and presentation order must never change identity. When
occurrence/pattern fingerprints are exported to SARIF they go in *alongside*
this key, never in place of it, or the migration would reset every alert's
history while claiming to improve correlation.

---

## 2. `fp-verdicts.json` — required fields
Expand Down
6 changes: 6 additions & 0 deletions identity/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# a package, so an installed "identity" distribution cannot shadow ours.
#
# Without this file the directory is a NAMESPACE portion: during the sys.path
# scan a namespace portion is only remembered, and a regular package found
# LATER on the path still wins -- even though the repo root was inserted first.
# `identity` is a real name on PyPI, so this is not hypothetical (Codex, PR #55).
163 changes: 163 additions & 0 deletions identity/pattern.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
`finding-pattern/v1` - the ONE implementation of Own's pattern-level finding
identity, and the domain's canonical definition of it (issue Own.NET#266).

pattern_id = sha1( path + "\\x1f" + rule + "\\x1f" + message )[:16]

This is the recipe `viz/apply_verdicts.finding_id` has always used
(`docs/fp-judge/verdict-contract.md` section 1); it moves here unchanged so that every
producer and consumer computes identity from one place instead of from one
place each. `apply_verdicts` now imports it, and its `finding_id` name survives
as an alias - the bytes are identical, so existing overlays keep matching.

WHY IT IS NOT "IMPROVED" HERE
-----------------------------
SHA-1 is not a security choice in this role; it is a *compatibility* one. Every
`fp-verdicts.json` overlay in existence is keyed by these exact 16 hex
characters, and the overlay's freshness guard only proves the verdicts were
judged against the same findings file - not that the ids can be recomputed
differently. Changing the algorithm, the separator, the truncation, or the
field order silently orphans every stored verdict: the overlay would simply
stop matching, and a clean report would be indistinguishable from a correct
one. Compatibility outranks a tidier hash.

WHAT IS DELIBERATELY *NOT* NORMALIZED
-------------------------------------
`path`, `rule` and `message` are joined **verbatim**, as UTF-8:

* no case folding - `Src\\App.cs` and `src\\app.cs` are different findings;
* no path-separator rewriting - a Windows-style and a POSIX-style spelling of
the same file are different ids, because the producer chose the spelling
and identity follows the emitted record, not a guess about the filesystem;
* no digit or whitespace collapsing in `message` - two findings whose
messages differ only by a number are different patterns.

The `contracts/finding-pattern-v1.json` vectors pin each of those, so a future
"harmless cleanup" fails a test instead of quietly re-keying the world.

NOT TO BE CONFUSED WITH THE SARIF FINGERPRINT
---------------------------------------------
`report/sarif.py` emits `partialFingerprints["ownAudit/v1"]`, which is a
DIFFERENT value computed a DIFFERENT way (rule/path/normalized-message joined
with newlines, full SHA-1, plus a `/ordinal` suffix for repeats). It exists to
keep GitHub code-scanning alerts correlated across commits and it is a legacy
GitHub-correlation key - **it is not `finding-pattern/v1`**. Its ordinal suffix
alone disqualifies it: identity must not depend on presentation order.

Line is never part of identity: it drifts, and it travels as data for locating
a finding, never for keying one. Two physical findings that share
`(path, rule, message)` share a `pattern_id` ON PURPOSE - one judged verdict
covers the repeated pattern. Telling those physical occurrences apart is the
job of `occurrence_id`, a different identity with different rules.
"""

from __future__ import annotations

import hashlib
import json
from pathlib import Path
from typing import Any, Mapping

#: The contract this module implements. Bump only with a synchronized change to
#: `contracts/finding-pattern-v1.json` and every consumer that pins it.
CONTRACT = "finding-pattern/v1"

#: Length of the hex digest kept, in characters. Part of the contract.
ID_LENGTH = 16

#: Unit separator: unambiguous join, cannot occur in a path/rule/message.
SEP = "\x1f"

CONTRACT_FILE = Path(__file__).resolve().parent.parent / "contracts" / "finding-pattern-v1.json"


def pattern_id(path: str, rule: str, message: str) -> str:
"""The `finding-pattern/v1` id for one finding's verbatim fields.

Takes the three fields directly rather than a dict, so a caller cannot
accidentally feed a *differently shaped* record and get a plausible-looking
id from the wrong keys.
"""
key = f"{path}{SEP}{rule}{SEP}{message}"
return hashlib.sha1(key.encode("utf-8")).hexdigest()[:ID_LENGTH]


def pattern_id_of(finding: Mapping[str, Any]) -> str:
"""`pattern_id` for a normalized finding record.

The record must carry `path`, `rule` and `message`; a missing one is an
error rather than an empty string, because silently hashing `""` would mint
a stable-looking id for an incomplete record and merge unrelated findings
under it.
"""
missing = [k for k in ("path", "rule", "message") if k not in finding]
if missing:
raise KeyError(
f"{CONTRACT}: cannot compute pattern_id, record is missing {missing}"
)
return pattern_id(finding["path"], finding["rule"], finding["message"])


def load_vectors(path: str | Path | None = None) -> dict[str, Any]:
"""The canonical vector file - the contract's executable half."""
return json.loads(Path(path or CONTRACT_FILE).read_text(encoding="utf-8"))


def _selftest() -> int:
"""Run the canonical vectors.

`PYTHONUTF8=1 PYTHONPATH=. python3 identity/pattern.py --selftest`
"""
fails: list[str] = []
doc = load_vectors()

if doc.get("contract") != CONTRACT:
fails.append(f"contract mismatch: file says {doc.get('contract')!r}, module says {CONTRACT!r}")
if doc.get("id_length") != ID_LENGTH:
fails.append(f"id_length mismatch: file {doc.get('id_length')!r} vs module {ID_LENGTH}")

for vec in doc.get("vectors", []):
got = pattern_id(vec["path"], vec["rule"], vec["message"])
if got != vec["pattern_id"]:
fails.append(f"{vec['name']}: got {got}, want {vec['pattern_id']}")
if len(got) != ID_LENGTH or any(c not in "0123456789abcdef" for c in got):
fails.append(f"{vec['name']}: {got!r} is not {ID_LENGTH} lower-case hex chars")

# The grouping vectors state, as data, which pairs must (not) collide.
by_name = {v["name"]: v for v in doc.get("vectors", [])}
for a, b in doc.get("same_pattern", []):
if pattern_id_of(by_name[a]) != pattern_id_of(by_name[b]):
fails.append(f"{a} and {b} must share a pattern_id")
for a, b in doc.get("distinct_patterns", []):
if pattern_id_of(by_name[a]) == pattern_id_of(by_name[b]):
fails.append(f"{a} and {b} must NOT share a pattern_id")

try:
pattern_id_of({"path": "a", "rule": "b"})
except KeyError:
pass
else:
fails.append("a record missing `message` must raise, not hash an empty field")

for f in fails:
print(f"FAIL: {f}")
n = len(doc.get("vectors", []))
print(f"identity/pattern selftest: {'OK' if not fails else 'FAIL'} "
f"({n} vectors, {len(doc.get('same_pattern', []))} grouping pairs, "
f"{len(doc.get('distinct_patterns', []))} distinctness pairs)")
return 1 if fails else 0


if __name__ == "__main__":
import sys

if "--selftest" in sys.argv:
raise SystemExit(_selftest())
if len(sys.argv) == 4:
print(pattern_id(sys.argv[1], sys.argv[2], sys.argv[3]))
raise SystemExit(0)
print(__doc__)
print("usage: PYTHONUTF8=1 PYTHONPATH=. python3 identity/pattern.py --selftest")
print(" PYTHONUTF8=1 PYTHONPATH=. python3 identity/pattern.py <path> <rule> <message>")
raise SystemExit(2)
Loading
Loading