Skip to content

Phase 2: Detection - #1

Merged
chriswayneh merged 9 commits into
masterfrom
phase-2-detection
Aug 19, 2026
Merged

Phase 2: Detection#1
chriswayneh merged 9 commits into
masterfrom
phase-2-detection

Conversation

@chriswayneh

Copy link
Copy Markdown
Owner

Phase 2 gives RedDock findings without letting them absorb observations. An observation still states what an adapter saw. A finding is a separate thing: a normalized conclusion one named detector drew from one or more observations, which cannot exist without them.

Phase 1   Discovery → Asset / Service / Observation → Evidence
Phase 2   Discovery → Observation → Detection → Finding → Evidence

The detector boundary

A detector is deliberately weaker than a discovery adapter. An adapter may contact a target after DockGuard allows it; a detector may not contact anything. It receives an immutable snapshot of one Dockyard and returns value objects — no database session, no socket, no subprocess, no target string, no operator-supplied option. There is nothing for it to widen, execute, or reach, which is why it needs no scope decision.

That is enforced structurally rather than by convention. tests/test_detection_contract.py parses the detection package and fails the build if a detector imports anything that could leave the process or touch the database, and asserts that nothing in detection calls eval, exec, compile, or __import__.

Everything a detector could get wrong belongs to the runner: building the snapshot, validating the output, computing identity, reconciling, resolving, writing evidence. A detector that raises, returns an unknown severity, uses an unusable rule id, names another Dockyard's data, or cites no observation is failed as a whole and its results are discarded. The other detectors still run, and a failed detector resolves nothing — not running is not evidence that an issue went away.

Two invariants

A finding must cite evidence. A conclusion with nothing behind it is refused, not stored with a caveat. FindingEvidence links each finding to the observations it was drawn from, their discovery run, and that run's hashed EvidenceRecord, so "which detector, from what observation, during which run, verified by which hash" is answerable without leaving the database.

A finding is never deleted. Identity is a SHA-256 fingerprint over the detector, the rule, and the asset and service concerned — deterministic across runs, restarts, and processes, which Python's randomized hash() would not be. One issue stays one row whose last_seen moves. An issue a later successful run no longer reproduces is marked resolved; an operator may suppress, accept, or reopen one but may not declare it resolved, because whether something is still reproduced is a fact about the data rather than an opinion about it.

Detectors

Three, and each says only what Phase 1 data actually supports.

Detector Reports
http.security_headers Plaintext transport, and response-level protections the response did not carry
service.rules A fixed table of protocol rules over services RedDock identified, plus disclosed versions
tls.certificates What certificate verification objected to

The false-positive controls are the point:

  • A header is only reported when the probe recorded that it looked for it, so "RedDock did not look" is never rendered as "the server did not send it". This required a Phase 1 change: the response observation now states the header set it examined.
  • Content-level headers are judged only on a response that represents how an endpoint normally answers. A 301 to HTTPS carrying no Content-Security-Policy is a correct configuration, not a finding.
  • HSTS is not reported over plaintext HTTP, where it is meaningless. The plaintext transport is the finding there.
  • A service rule needs an identification observation. A port number alone still says nothing.
  • Severity is restrained: missing hardening headers are low, and severity and confidence stay separate fields.

What is deliberately absent: there is no TLS protocol-version rule. The probe negotiates with a default client, so it can only ever record a version a current client accepted, and a rule about obsolete versions could never fire from RedDock's own data. Shipping it would suggest a capability that does not exist.

CVE enrichment

RedDock downloads no CVE data. This ships the boundary plus a local catalogue reader behind REDDOCK_CVE_CATALOG, off by default.

An association never creates a finding, never changes a severity, confidence, or status, and attaches to the version-disclosure finding that already stood on its own evidence. Matching is exact product and version only — version ranges are not interpreted, because an inference printed beside a CVE identifier reads as a result. A missing or malformed catalogue is a warning on the run, not a failure, and each run records which enrichment source was in effect so a finding with no CVE reference can be told apart from one RedDock could not enrich. A test asserts the same data produces the same rating with and without a catalogue loaded.

The limitation is documented rather than hidden: out of the box, no finding carries a CVE reference. See ADR 0007.

Preflight fixes

Three issues found before building on top of the existing code, all in the same branch:

  1. The HTTP probe recorded no statement of which headers it examined, so any header detector built on it would generate false positives on pre-Phase-2 evidence. It now records the examined set alongside the headers present, and examines the response-level protections a detector needs.
  2. An unverified TLS handshake returns an empty peer certificate, so verified: false alone supports no claim about a certificate. The probe now records the code and message OpenSSL gave.
  3. The probe User-Agent was a hardcoded RedDock/0.2.1, which the 0.2.1 changelog claims tracks the application version. It now derives from settings, and a test asserts the application, API, and both package manifests report one version.

Schema

Purely additive: three new tables, no existing column changed, so create_all upgrades a deployed database in place. tests/test_schema_upgrade.py now builds a real 0.2.1-shaped database, runs a full detection over data the previous release wrote, and asserts every Phase 1 table keeps the shape it had.

That constraint shaped a decision rather than merely being stated. Detection artifact hashes live on the detection run because evidence_records.discovery_run_id is NOT NULL and relaxing it would be the first destructive schema change — which this architecture already says arrives with versioned migrations rather than an ad hoc alteration. Both halves are written by the same store, under the same root, both SHA-256 hashed.

Validation

  • 261 backend tests (was 119), 16 frontend tests (was 7); ruff, eslint, tsc, and the production build clean
  • Container built and the end-to-end smoke test run against it, now covering detection, findings, evidence traceability, deduplication, and that detection accepts no operator parameters — against RedDock's own origin inside the container, so nothing outside the machine is contacted
  • Security review of the diff found no HIGH or MEDIUM issues: the new evidence scope is validated against a closed set before path construction, every findings query is dockyard-scoped, the detection endpoint takes no operator input, and no new deserialization or dangerouslySetInnerHTML was introduced

Safety

Phase 2 adds no exploitation, credential attack, brute force, payload execution, evasion, persistence, lateral movement, or autonomous behaviour, and no active vulnerability testing. Detection reasons over data an earlier non-invasive discovery recorded; it sends nothing and confirms nothing by attempting it. DockGuard is unchanged and remains authoritative for everything that does reach a target. No AI is involved: every detector is a deterministic rule, and the same input produces the same findings.

🤖 Generated with Claude Code

chriswayneh and others added 9 commits August 19, 2026 02:41
A detector cannot honestly report a missing security header unless it can
tell an absent header from one RedDock never looked for, so the response
observation now states the header set that was examined alongside the
headers that were present, and the examined set covers the response-level
protections a Phase 2 detector needs.

Certificate verification failures now record the code and message OpenSSL
gave. An unverified handshake returns an empty peer certificate, so without
the verification reason there is nothing to say about a certificate beyond
"it did not verify".

The probe User-Agent is also derived from the application version rather
than repeated as a literal, which is what the 0.2.1 changelog claims and
what would otherwise drift at the next release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Detection turns observations into findings without letting either concept
absorb the other. An observation stays a dated statement of what an adapter
saw; a finding is a normalized conclusion one named detector drew, and it
cannot exist without the observations that support it.

The detector contract is deliberately weaker than the discovery adapter
contract. A detector receives a frozen snapshot of one Dockyard and returns
value objects. It gets no session, no socket, no subprocess, no target and
no operator input, and tests read the package to prove it rather than
trusting the docstring. Validation, identity, reconciliation, resolution and
evidence all belong to the runner, so a detector that returns something
malformed is failed as a whole rather than half-believed.

Identity is a SHA-256 fingerprint over detector, rule and the asset and
service the finding is about, which keeps one issue as one row across runs,
restarts and processes. A finding that stops being reproduced is resolved
rather than deleted, and a decision an operator made about it is never
overwritten by a later run.

The first three detectors only say what Phase 1 data actually supports.
Missing security headers are reported only for headers the probe examined,
only for responses that represent how an endpoint normally answers, and at
restrained severity. Service rules run off protocols RedDock identified, not
port numbers it recognised. TLS reports what certificate verification
objected to, and deliberately says nothing about protocol versions, because
the probe negotiates with a current client and could never observe an
obsolete one.

CVE enrichment ships as a boundary with a local catalogue behind it. RedDock
downloads nothing, matches only an exact product and version, records where
the association came from, and never lets a match change a severity, a
confidence or a status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Detection reads stored state and contacts nothing, so a run completes inside
the request rather than leaving something in flight to recover after a
restart. The request body is empty by design: there is no target, no
detector selection and no option, so there is no operator-supplied value for
a detector to act on.

Findings are filterable by status, severity, detector, asset and service,
with every filter validated before it reaches a query and the Dockyard
always part of it. An operator may suppress, accept or reopen a finding but
never declare it resolved, because whether an issue is still reproduced is a
fact about the data rather than an opinion about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The React workspace gains two sections rather than a new application:
Detection, which states what a detection run reads and what each registered
detector consumes before running one, and Findings, which lists what came out
of it.

The presentation stays restrained deliberately. Severity and confidence are
separate columns, not one blended number; there is no risk score, no gauge and
no aggregate rating, because RedDock does not compute one. Opening a finding
shows the detector and rule that produced it, the observation it was drawn
from, the discovery and detection runs involved and the SHA-256 of the
retained artifact, so the reader can follow the claim back to the evidence
instead of trusting the label.

Screenshots are recaptured against a real local container so the documentation
shows v0.3.0 rather than a release the product has moved past.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Aligns the application, API, and both package manifests on 0.3.0 and moves the
reported phase to Detection, with a test asserting that alignment so the next
release cannot drift the way the last one nearly did.

Documentation now describes what is built rather than what is planned. The
README hero claim about evidence-backed findings is finally true, so it stays;
the roadmap moves Phase 2 to complete and names Phase 3 as next; the
architecture gains the detection boundary, the finding lifecycle, the detection
half of RedLedger, and the reason detection artifact hashes live on the run
rather than in evidence_records. SECURITY.md gains a detection control list and
is explicit that RedDock still performs no active vulnerability testing:
detection concludes from what was recorded, it does not confirm by attempting.

Two ADRs record the decisions that were not obvious: that a detector concludes
but never reaches, and that a CVE association is enrichment rather than a
conclusion. The smoke test now proves the whole path end to end in CI, against
RedDock's own origin inside the container.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A detection run completes inside its request, so one still marked active at
startup did not finish. Leaving it that way would have been worse than
untidy: an overlapping run is refused while one looks active, so a run left
behind by a crash would have blocked that Dockyard's detection permanently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deduplication keys on the fingerprint, so a detector emitting the same rule
twice for one service would quietly overwrite its own first finding and leave
the run reporting more findings than exist. That is a detector failing to
distinguish two things it believes are different, which is a scope key it did
not set, so it is refused rather than absorbed.

The three shipped detectors cannot hit this — each rule fires at most once per
endpoint — but the check belongs with the other output validation rather than
waiting for the fourth detector to find it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The findings page has a workspace picker, and the detail panel kept whatever
was open across a change of Dockyard. In a product whose central promise is
that workspaces are isolated, showing one workspace's finding beside another
workspace's list is exactly the wrong thing to render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… pytest

The Phase 2 test modules share a helper through `from tests.phase1 import
Recorder`, which only resolves when the working directory is on sys.path.
That is true for `python -m pytest` and false for the bare `pytest` CI runs,
so four modules failed to collect there while passing locally.

Adding the package marker fixes the import under any invocation. The lesson
is the local check, not the missing file: verification has to run the command
CI runs, so this was reproduced with `pip install -e ".[dev]"` and bare
`ruff`/`pytest` before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chriswayneh
chriswayneh merged commit 6a5fbb5 into master Aug 19, 2026
6 checks passed
@chriswayneh
chriswayneh deleted the phase-2-detection branch August 19, 2026 11:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant