The four layers of the check
Integrity checking is layered, and each layer catches modifications that survive the one before it.
1. Serialisation. Function.prototype.toString.call(fn) on a genuine built-in returns function name() { [native code] }. A JavaScript replacement returns its own source. This is the oldest check in the family and is covered in detail in how toString reveals hooked functions.
2. Descriptor shape and location. Most navigator properties are accessors defined on Navigator.prototype, not data properties on the instance. Object.getOwnPropertyDescriptor(navigator, 'userAgent') on an unmodified browser returns undefined, because the property is not an own property of the object at all - it is inherited. A replacement assigned directly to navigator creates an own data property with value and writable keys, which is structurally the wrong shape in the wrong place.
3. Brand checks. Native methods and getters validate their receiver in C++ before doing anything. Calling Object.getOwnPropertyDescriptor(Navigator.prototype, 'userAgent').get.call({}) on a real browser throws a TypeError - the engine refuses a plain object that is not a real Navigator. A JavaScript replacement usually has no such check and cheerfully returns a string, which is a positive signal that the getter is not native.
4. Honeypots. Reading a property that has never existed - a made-up name on navigator - must return undefined. A broadly written Proxy that intercepts every get in order to serve modified values will also answer for names nobody defined, which is a contradiction no real browser produces.
Why the checks compose so well
Each layer independently forces the modification to become more elaborate, and each elaboration creates new surface for the next layer.
Assigning a value directly to navigator.userAgent fails the descriptor check. Moving to Object.defineProperty with a getter fixes the location but fails the toString check, because the getter is a JavaScript function. Patching Function.prototype.toString to lie about that getter fixes the serialisation but means toString itself is now a JavaScript function - so Function.prototype.toString.call(Function.prototype.toString) becomes the contradiction. Reaching for a Proxy to handle the whole object generically fixes the recursion but answers for honeypot names and changes the brand-check behaviour.
There is no fixed point inside JavaScript. The layer doing the modifying is always itself made of JavaScript, and JavaScript is exactly what these checks are able to distinguish from engine code. This is a structural result, not a matter of implementation quality.
What this implies for tooling choices
The practical conclusion is that integrity checks partition tooling into two categories rather than ranking it on a scale. Anything that modifies a running browser from page-world or extension JavaScript is in principle observable, because the modification leaves JavaScript objects where engine objects should be. Anything that changes the value inside the engine before it is ever exposed is not, because there is no replacement to find - the native function is still native and simply returns a different value.
That is why patched browser builds and engine forks behave categorically differently from injection scripts, and it connects directly to realm coherence detection: engine-level changes propagate to every realm automatically, while injected changes must be re-applied per realm by a layer that integrity checks can see.
For teams whose goal is collecting public web data rather than maintaining a browser, this is a strong argument for not owning the problem at all. A managed web data API runs real engines whose built-ins are genuinely untouched, so integrity checks pass because there is nothing to detect rather than because something was hidden well.