diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1311847..4e8de42 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,10 +4,16 @@ ```text Browser → React UI (static files) → FastAPI → DockGuard → discovery adapter - ↓ ↓ - SQLite evidence files + │ ↓ + │ SQLite ← evidence files + ↓ ↓ + detector ←── snapshot of stored state + ↓ + findings ``` +Two paths leave the API and only one of them touches a network. Discovery goes out through DockGuard to a target and records what it saw. Detection goes the other way: it reads what is already stored, concludes something about it, and writes findings back. A detector never reaches a target, which is what makes the second path safe to run without a scope decision. + The production image builds the React/Vite application and serves it as static content from the same FastAPI process that exposes `/api`. A named Docker volume holds SQLite at `/var/lib/reddock` and retained evidence at `/var/lib/reddock/evidence`. There is deliberately no reverse proxy, separate frontend service, queue, or remote dependency. ## Boundaries @@ -18,6 +24,8 @@ The production image builds the React/Vite application and serves it as static c - `backend/app/services.py`: Dockyard and scope operations. - `backend/app/inventory.py`: asset, service, and observation persistence rules. - `backend/app/discovery/`: the adapter contract, adapters, registry, and run orchestration. +- `backend/app/detection/`: the detector contract, detectors, registry, fingerprints, CVE enrichment, and run orchestration. +- `backend/app/findings.py`: finding persistence, deduplication, and lifecycle rules. - `backend/app/evidence.py`: the evidence store. - `backend/app/models.py` and `schemas.py`: persistence mappings and input/output contracts. - `frontend/src`: presentation and API client only. @@ -29,7 +37,7 @@ Every operator-supplied target passes through `normalize_target` before anything - IPv4 and IPv6 addresses in strict textual form only — integer, packed, and zero-padded forms are refused so `3232235777` can never quietly become `192.168.1.1`. - Networks canonicalized to their network address (`192.168.1.37/24` → `192.168.1.0/24`). - Hostnames lowercased, stripped of a trailing dot, IDNA-encoded per label, and validated. -- URLs reduced to an origin: scheme, host, and port. Paths, queries, fragments, and embedded credentials are rejected or dropped, because Phase 1 probes an origin and not a location. +- URLs reduced to an origin: scheme, host, and port. Paths, queries, fragments, and embedded credentials are rejected or dropped, because RedDock probes an origin and not a location. A canonical target may contain only `[A-Za-z0-9._:/-]` and can never begin with `-`. This is what makes argument injection through a target string impossible rather than merely unlikely. @@ -91,25 +99,80 @@ prepare → execute → parse → normalize → artifacts - **Service** — a transport endpoint on an asset, unique on `(asset, transport, port)`. `service_name`, `product`, and `version` stay null until an adapter actually identified them. A conventional port number is not evidence: nmap's port-table guess is discarded, so TCP/22 open is recorded as TCP/22 open and nothing more. - **Observation** — a dated, adapter-attributed statement of what was seen, with a confidence of `observed` (RedDock saw it) or `reported` (the target said so). Observations accumulate as history and are never reconciled. - **DiscoveryRun** — one auditable request: adapter, profile, requested and normalized target, DockGuard decision and reason, status, counts, and evidence path. Denied requests are stored too, because an audit trail that only records successes is not an audit trail. -- **EvidenceRecord** — a hashed pointer to one retained artifact. +- **DetectionRun** — one auditable detection: which detectors ran, what each of them did or failed to do, how much state was read, how many findings were produced, created and resolved, which enrichment source was in effect, and the hashes of the two documents it retained. It has no target and no DockGuard decision, because it contacts nothing. +- **Finding** — a normalized security-relevant conclusion one named detector drew. Identity is a SHA-256 `fingerprint` over the detector, the rule, and the asset and service concerned, unique within a Dockyard, so repeated detection updates one row instead of accumulating duplicates. Severity and confidence are separate fields: how much this would matter, and how sure RedDock is that it is true, are different questions and blending them loses both. +- **FindingEvidence** — one row per observation that supported a finding, carrying the discovery run and the hashed `EvidenceRecord` that observation came from. +- **EvidenceRecord** — a hashed pointer to one retained discovery artifact. + +**Observation ≠ Finding.** An observation says what happened; a finding says what it means. They remain separate rows, separate lifecycles and separate concepts: discovery alone never produces a finding, detection never edits an observation, and a finding that cites no observation is refused rather than stored. What Phase 2 adds is the arrow between them, not a merge. + +### Finding lifecycle + +```text + detector reproduces it + (new) ──────────────► open ◄──────────── operator reopens + │ ▲ + detector no longer │ │ detector reproduces it again + reproduces it ▼ │ + resolved + │ + operator decides ──────┴──────► suppressed / accepted +``` + +Four states, and only three of them are an operator's to set. `resolved` is RedDock's answer to a question about the data — is this still reproduced? — so the API refuses to let an operator declare it, and nothing here ever deletes a finding: an issue that stopped being reproduced is more useful recorded as resolved than erased. `suppressed` and `accepted` are decisions a person took responsibility for, so a later run leaves them alone even when it sees the issue again. -**Observation ≠ Finding.** An observation says what happened; a finding says what it means. RedDock records the former and, in Phase 1, deliberately refuses to imply the latter. Findings, severity, and scoring belong to Phase 2. +Resolution is scoped to the detector that just ran successfully. A detector that raised, or that returned output RedDock refused, resolves nothing, because not running is not evidence that an issue went away. -## Evidence flow (RedLedger foundation) +## Detection boundary -Every completed run writes: +A detector is deliberately weaker than a discovery adapter. An adapter may contact a target; a detector may not contact anything. It receives an immutable snapshot of one Dockyard's assets, services and observations and returns value objects — no session, no socket, no subprocess, no target string, no operator-supplied option. `tests/test_detection_contract.py` parses the detection package and fails the build if a detector imports anything that could reach outside the process or touch the database, so the boundary is checked rather than asserted. ```text -evidence/// +snapshot → detect → validate → normalize → findings +``` + +Everything except `detect` belongs to the runner. It builds the snapshot, validates what came back, computes identity, reconciles against what is known, resolves what is absent and writes evidence. A detector that returns something malformed — an unknown severity, a rule id that is not a rule id, a finding about another Dockyard's asset, a finding citing no observation — is failed as a whole and its results are discarded, and the other detectors still run. + +| Detector | Reads | Reports | +| --- | --- | --- | +| `http.security_headers` | `http_response`, `http_header` | Plaintext transport, and response-level protections the response did not carry, for the headers the probe examined | +| `service.rules` | `service_identified` and the service inventory | A fixed table of protocol rules over services RedDock identified, and disclosed product versions | +| `tls.certificates` | `tls_session` | What certificate verification objected to | + +Three things keep this from producing the usual noise. 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". Content-level headers are only judged on a response that represents how an endpoint normally answers, so a 301 to HTTPS carrying no Content-Security-Policy is not a finding. And a service rule needs an identification observation, so a port number alone still says nothing: TCP/23 open is TCP/23 open. + +The scope is also narrower than it could look. RedDock does not enumerate supported TLS versions or cipher suites — the HTTP probe negotiates with a default client, so it can only ever record a version a current client accepted — and a rule about obsolete protocol versions would therefore never be able to fire from RedDock's own data. It is left out rather than shipped as decoration. + +## CVE enrichment + +RedDock fetches no CVE data and has no vulnerability feed. Phase 2 ships the boundary and a local catalogue reader behind it, enabled only when an operator sets `REDDOCK_CVE_CATALOG` to a JSON file. A match requires an exactly equal product and version; version ranges are not interpreted, because a range is an inference and an inference printed beside a CVE identifier reads as a result. + +An association never creates a finding, never changes a severity, a confidence or a status, and is attached to the version-disclosure finding that already stood on its own evidence. A catalogue that is missing, oversized or malformed is recorded as a warning on the detection run rather than failing it, and each detection run states which enrichment source was in effect so a finding with no CVE reference can be told apart from one RedDock could not enrich. See [ADR 0007](docs/adr/0007-cve-enrichment-is-an-association.md). + +## Evidence flow (RedLedger) + +Every completed run writes through the same store: + +```text +evidence/// metadata.json adapter, tool version, profile, targets, DockGuard decision, invocation, timestamps, counts, artifact hashes raw/ unmodified tool output normalized/result.json the normalized assets and observations + +evidence//detection// + metadata.json detectors and their outcomes, enrichment source, inputs read, + counts, timestamps, artifact hashes + normalized/result.json the findings produced and the fingerprints resolved ``` -Paths are built from integer identifiers and a validated artifact name, and the resolved destination is checked to be inside its run directory, so no operator input can direct a write elsewhere. Each artifact is SHA-256 hashed and recorded as an `EvidenceRecord`. Session material such as cookies is deliberately never retained. +Paths are built from integer identifiers, a fixed scope name and a validated artifact name, and the resolved destination is checked to be inside its run directory, so no operator input can direct a write elsewhere. Every artifact is SHA-256 hashed. Session material such as cookies is deliberately never retained, and detection has nothing raw to retain because it contacts nothing. -This is the foundation only. Validation state, evidence packages, and portable exports belong to later phases. +A finding is therefore checkable end to end. `FindingEvidence` names the observations it was drawn from; each of those names its discovery run and that run's hashed `EvidenceRecord`; the detection run records the hash of the normalized result the finding appears in. Which detector produced it, from what observation, during which run, and which hash verifies it are all answerable without leaving the database. + +Detection artifact hashes are recorded as columns on the detection run rather than as `evidence_records` rows, because that table's `discovery_run_id` is NOT NULL and Phase 2 stays purely additive; relaxing it would be the first destructive schema change, and this architecture already says that comes with versioned migrations rather than an ad hoc alteration. Unifying both halves behind one table is the first job of that migration. + +Evidence packages and portable exports still belong to later phases. ## Trust boundaries @@ -119,17 +182,30 @@ This is the foundation only. Validation state, evidence packages, and portable e | API → DockGuard | Every target, always, server-side. | | DockGuard → adapter | Only normalized targets and internally generated options cross. Operator strings never become flags. | | Adapter → target | Non-invasive profiles only, without a shell, under a timeout, with output bounds. | -| Target → RedDock | Tool output and HTTP headers are untrusted data. They are stored and displayed as text, never executed, and self-reported values are marked `reported`. | +| Target → RedDock | Tool output and HTTP headers are untrusted data. They are stored and displayed as text, never executed, and self-reported values are marked `reported`. A detector reads that same data and may draw a conclusion from it; it still cannot act on it. | +| API → detector | Only an immutable snapshot of one Dockyard crosses. No session, socket, subprocess, target or operator option is reachable from a detector, and its output is validated before any of it is stored. | | RedDock → disk | Writes confined to the database file and the evidence root. | ## Concurrency and restart -Discovery runs on a `ThreadPoolExecutor` bounded to the concurrent-run limit; Phase 1 introduces no Redis, queue, or worker service. If the process stops while a run is in flight, startup marks that run failed with "Interrupted by a RedDock restart" rather than leaving it looking active or pretending it completed. +Discovery runs on a `ThreadPoolExecutor` bounded to the concurrent-run limit; there is no Redis, queue, or worker service. If the process stops while a run is in flight, startup marks that run failed with "Interrupted by a RedDock restart" rather than leaving it looking active or pretending it completed. + +Detection is synchronous. It reads stored state and contacts nothing, so there is nothing to wait on: the run completes inside the request, the response describes a finished run, and there is no in-flight detection state for a restart to recover. A second detection run on the same Dockyard while one is in flight is refused rather than interleaved. + +| Detection limit | Value | Why | +| --- | --- | --- | +| Assets per snapshot | 2 000 | A snapshot cannot grow without bound | +| Observations per snapshot | 20 000 | The newest are read, so a long history stays bounded | +| Findings per detector per run | 500 | A detector that exceeds it fails rather than being silently truncated | +| Evidence references per finding per run | 20 | A finding cannot drag an unbounded citation list behind it | +| CVE catalogue | 5 MiB, 20 000 entries | An operator-supplied file is still input | ## Persistence evolution -Database setup is isolated in `backend/app/database.py` and each domain model owns its table definition. Phase 1 is purely additive — it adds tables and changes no existing column — so `create_all` upgrades a Phase 0 database in place without data loss, which `tests/test_schema_upgrade.py` verifies against a real 0.1.0-shaped database. Before the first destructive schema change, introduce versioned Alembic migrations rather than altering deployed tables ad hoc. +Database setup is isolated in `backend/app/database.py` and each domain model owns its table definition. Every phase so far is purely additive — it adds tables and changes no existing column — so `create_all` upgrades a deployed database in place without data loss. `tests/test_schema_upgrade.py` verifies that against real 0.1.0-shaped and 0.2.1-shaped databases, including running a full detection over data written by the previous release. Before the first destructive schema change, introduce versioned Alembic migrations rather than altering deployed tables ad hoc. + +That constraint has already shaped a decision rather than merely being stated: detection artifact hashes live on the detection run because `evidence_records.discovery_run_id` cannot be relaxed additively. ## AI boundary -AI is not integrated in Phase 1 and is optional thereafter. It may propose structured actions, but DockGuard evaluates them exactly as it evaluates an operator's, and it never receives shell access or the ability to widen scope. RedDock must remain useful with no AI provider configured. +AI is still not integrated, and remains optional whenever it arrives. It may propose structured actions, but DockGuard evaluates them exactly as it evaluates an operator's, and it never receives shell access or the ability to widen scope. Nothing in detection is AI-driven: every detector is a deterministic rule over recorded data, and the same input produces the same findings. RedDock must remain useful with no AI provider configured. diff --git a/CHANGELOG.md b/CHANGELOG.md index e861922..21cb001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,54 @@ All notable changes to RedDock are documented here. +## [0.3.0] — Phase 2 Detection + +Observations can now become findings. They remain separate concepts: an observation states what an adapter saw, a finding states what one named detector concluded from one or more of them, and a finding that cites no observation is refused rather than stored. + +### Added + +- Detector contract: a detector receives an immutable snapshot of one Dockyard and returns value objects, with no database session, socket, subprocess, target, or operator-supplied option in reach +- Detector registry with an explicit, fixed set; nothing is discovered, imported by name, or loaded from a plugin directory at runtime +- DetectionRun: an auditable record of which detectors ran, what each did or failed to do, how much state was read, what was produced and resolved, and which enrichment source was in effect +- Finding model with separate severity and confidence, a stable SHA-256 fingerprint, and links to the observations that support it +- Finding lifecycle: `open`, `resolved`, `suppressed`, and `accepted`, where resolution is RedDock's answer about the data and suppression and acceptance are the operator's +- FindingEvidence linking each finding to its observations, their discovery run, and the hashed RedLedger artifact behind them +- `http.security_headers` detector: plaintext transport and absent response-level protections, for the headers the probe recorded that it examined +- `service.rules` detector: a fixed table of protocol rules over services RedDock identified, plus disclosed product versions +- `tls.certificates` detector: what certificate verification objected to, using the code and message OpenSSL gave +- CVE enrichment boundary with an optional local catalogue behind `REDDOCK_CVE_CATALOG` +- API for detectors, detection runs, findings, finding detail with evidence, and operator status decisions +- Findings and Detection sections in the workspace, and a Dockyard-scoped Findings page +- ADR 0006 (detection boundary) and ADR 0007 (CVE enrichment is an association) + +### Changed + +- The HTTP probe records the header set it examined alongside the headers that were present, so a detector can tell an absent header from one RedDock never looked for +- The HTTP probe records the code and message OpenSSL gave when certificate verification failed, because an unverified handshake returns an empty peer certificate +- The HTTP probe retains `x-content-type-options`, `content-security-policy`, and `x-frame-options` in addition to the previous allowlist +- The HTTP probe User-Agent is derived from the application version rather than repeated as a literal +- The evidence store writes detection documents under a `detection` scope, so a detection run and a discovery run that share an identifier cannot share a directory +- The dashboard reports open findings; the observations view states that a detector, not the observation, produces interpretation + +### Security + +- Detection contacts nothing and takes no operator parameters: the request body is empty by design, so no operator string reaches a detector +- A detector that raises, returns malformed output, or names data outside its Dockyard is failed as a whole; its results are discarded and it resolves nothing +- Findings are never deleted, and an operator cannot declare one resolved +- Severity is stated conservatively and separately from confidence; RedDock produces no risk score, CVSS vector, or aggregate rating +- No CVE data is downloaded, matching is exact-version only, and an association never changes a severity, confidence, or status +- Detection snapshots, per-detector output, per-finding evidence references, and an operator-supplied catalogue are all bounded + +### Testing + +- Structural tests that parse the detection package and fail the build if a detector could reach a network, a process, the filesystem, or the database +- Detection orchestration tests for deduplication, resolution, reopening, operator decisions, detector failure isolation, malformed output, Dockyard isolation, and deterministic evidence +- A fingerprint test that runs in separate processes under different `PYTHONHASHSEED` values +- Detector tests covering false-positive avoidance: unexamined headers, redirects, server errors, scheme handling, and port numbers without an identification +- Schema-upgrade test proving a 0.2.1-shaped database upgrades in place and runs a full detection on data the previous release wrote +- A version test asserting the application, the API, and both packages report one version +- The end-to-end smoke test now covers detection, findings, evidence traceability, and deduplication + ## [0.2.1] — Phase 1 Discovery, finalized Phase 1 remains as released in 0.2.0; this is a corrective patch release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 875e3a3..ae7e4d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ cd frontend && npm ci && npm run lint && npm run check && npm run test && npm ru docker compose build ``` -Backend development needs Python 3.13; running RedDock itself needs only Docker. To verify the full discovery path end to end against loopback: +Backend development needs Python 3.13; running RedDock itself needs only Docker. To verify the full discovery and detection path end to end against loopback: ```bash docker compose up -d --build && python scripts/smoke_test.py @@ -18,9 +18,10 @@ docker compose up -d --build && python scripts/smoke_test.py ## Guidelines -- Do not add exploitation, credential attacks, vulnerability detection, or autonomous execution without an approved phase and DockGuard design. +- Do not add exploitation, credential attacks, active vulnerability testing, or autonomous execution without an approved phase and DockGuard design. - Every target must reach a tool through DockGuard. Never pass operator-supplied values to a subprocess as flags, and never build a command string. -- Record what was observed, not what it means. Severity, scoring, and findings belong to Phase 2. +- An adapter records what was observed. A detector says what it means, from stored observations only: it may not open a socket, start a process or reach the database, and a finding it produces must cite the observations behind it. +- Do not inflate a rating. A missing hardening header is not a high, a version banner is not a vulnerability, and a CVE association is not a test result. - Preserve the API/domain/persistence/UI boundaries. - Add tests for observable behavior and update documentation when behavior changes. - Use clear names and explain non-obvious safety decisions. diff --git a/README.md b/README.md index 6f3bacc..4b40477 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ Container-native security assessment and validation platform with controlled exe [![Python](https://img.shields.io/badge/Python-3.13-3776AB?logo=python&logoColor=white)](https://www.python.org/) [![CI](https://github.com/chriswayneh/RedDock/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/chriswayneh/RedDock/actions/workflows/ci.yml) [![License](https://img.shields.io/github/license/chriswayneh/RedDock)](LICENSE) -[![Phase](https://img.shields.io/badge/phase-1%20Discovery-C1121F)](ROADMAP.md) +[![Phase](https://img.shields.io/badge/phase-2%20Detection-C1121F)](ROADMAP.md) -**Current release:** [v0.2.1](https://github.com/chriswayneh/RedDock/tags) — Phase 1 Discovery · Active development +**Current release:** [v0.3.0](https://github.com/chriswayneh/RedDock/tags) — Phase 2 Detection · Active development [Quick Start](#quick-start) · [Current Capabilities](#what-you-get) · [Architecture](#architecture) · [Security](#security-by-design) · [Roadmap](ROADMAP.md) · [Contributing](CONTRIBUTING.md) @@ -25,11 +25,11 @@ Container-native security assessment and validation platform with controlled exe RedDock explores how security tooling can become portable, container-native, policy-controlled, reproducible, and evidence-driven instead of a collection of host-specific scripts. It is designed for authorized environments and intentionally grows through small, verified phases. -Its operating model is simple: **AI proposes. Policy authorizes. Tools execute. Evidence proves.** Phase 1 implements the middle two: an explicit authorized scope, a policy boundary called DockGuard that every target must pass, and non-invasive discovery adapters that produce hashed evidence. There is still no AI integration, vulnerability detection, exploitation, credential attack, or payload of any kind. +Its operating model is simple: **AI proposes. Policy authorizes. Tools execute. Evidence proves.** Three of the four are implemented: an explicit authorized scope, a policy boundary called DockGuard that every target must pass, non-invasive discovery adapters that produce hashed evidence, and detectors that turn those observations into findings a reviewer can trace back to the evidence behind them. There is still no AI integration, exploitation, credential attack, active vulnerability testing, or payload of any kind. ## What You Get -| Capability | Phase 1 implementation | +| Capability | Current implementation | | --- | --- | | Runtime | One Dockerized application that serves the UI and API on the same origin | | Workspaces | Dockyards that own an explicit authorized scope | @@ -37,6 +37,10 @@ Its operating model is simple: **AI proposes. Policy authorizes. Tools execute. | Discovery | Nmap host and TCP service discovery, plus a single-request HTTP origin probe | | Inventory | Normalized assets and services that reconcile across repeat discovery | | Observations | Dated, adapter-attributed records of what was seen — never findings | +| Detection | Deterministic detectors that read stored observations and reach nothing | +| Findings | Normalized conclusions with separate severity and confidence, deduplicated by fingerprint | +| Lifecycle | Findings resolve rather than disappear, and operator decisions survive later runs | +| CVE enrichment | A boundary with an optional local catalogue; an association, never a verdict | | Evidence | Raw output, normalized result, and metadata per run, each SHA-256 hashed | | Persistence | SQLite and evidence stored in a named Docker volume | | Safety | Non-invasive profiles only; no scripting, brute force, evasion, or exploitation | @@ -45,16 +49,28 @@ Its operating model is simple: **AI proposes. Policy authorizes. Tools execute.
+RedDock findings view showing severity, confidence, status, the detector that produced a finding, and the hashed evidence behind it + +Findings: severity and confidence stated separately, with the detector, the observation, and the SHA-256 that supports each one. + +

+ RedDock dashboard showing workspace metrics and a discovery run audit trail The dashboard: workspace metrics and the discovery audit trail, including a run DockGuard denied.

-RedDock Dockyard workspace showing the discovery launch flow beside a DockGuard ALLOWED decision +RedDock Dockyard workspace showing the authorized scope beside a DockGuard ALLOWED decision The Dockyard workspace: a target must pass DockGuard before discovery can be launched. +

+ +RedDock detection view showing the registered detectors and a completed detection run + +Detection: the registered detectors, what each of them reads, and what a completed run produced. +
## Quick Start @@ -78,8 +94,9 @@ Stop the application with `docker compose down`. The `reddock-data` volume holds 3. Enter a target and ask DockGuard for a decision. It answers `ALLOWED` or a specific denial with the reason and the scope entry that decided it. 4. Run a safe discovery profile. The server re-evaluates DockGuard immediately before the adapter is invoked, so an out-of-scope target is never reached. 5. Results normalize into assets, services, and observations, and the run's raw output, normalized result, and metadata are retained and hashed. +6. Run detection. It contacts nothing: every registered detector reads what the Dockyard already recorded and returns findings, each naming the rule that produced it and the observations it was drawn from. -Run the same discovery again and RedDock updates what it already knows rather than duplicating it, while every observation is kept as history. +Run the same discovery again and RedDock updates what it already knows rather than duplicating it, while every observation is kept as history. Run detection again and the same issue stays one finding whose `last_seen` moves, while an issue that is no longer reproduced is marked resolved rather than quietly removed. ## Architecture @@ -93,9 +110,16 @@ flowchart TB Adapter --> Normalize[Assets · Services · Observations] Normalize --> Database[(SQLite named Docker volume)] Adapter --> Evidence[(Hashed evidence)] + API --> Detect[Detector] + Database --> Detect + Detect --> Findings[Findings] + Findings --> Database + Findings -.cites.-> Evidence ``` -The production image builds the React application and serves it from the same FastAPI process that exposes `/api`. There is deliberately no reverse proxy, separate frontend service, queue, or remote dependency; discovery runs on a small bounded thread pool inside the application. See [ARCHITECTURE.md](ARCHITECTURE.md) for the scope model, adapter boundary, and trust boundaries. +Only one of those two paths touches a network. Discovery goes out through DockGuard to a target; detection reads what is already stored and never leaves the process, which is why it needs no scope decision. + +The production image builds the React application and serves it from the same FastAPI process that exposes `/api`. There is deliberately no reverse proxy, separate frontend service, queue, or remote dependency; discovery runs on a small bounded thread pool inside the application and detection runs inline. See [ARCHITECTURE.md](ARCHITECTURE.md) for the scope model, the adapter and detector boundaries, and the trust boundaries. ## Security by Design @@ -107,14 +131,18 @@ The production image builds the React application and serves it from the same Fa - **Names and addresses stay separate.** A hostname is never authorized because it resolves into an authorized network, and there is no wildcard or subdomain expansion. - **Tools never receive operator flags.** Argument vectors are generated internally from a fixed table of safe options, executed without a shell, bounded by timeouts, and built only from targets normalized to a character set that cannot form an option. - **Dangerously broad scope is rejected.** A scope entry may not cover more than 256 addresses, and a default route is never valid. -- **Observations are not findings.** RedDock records what an adapter saw and assigns no severity, score, or verdict. +- **Observations are not findings.** An observation records what an adapter saw and carries no severity or verdict. A finding is a separate thing: a normalized conclusion one named detector drew, which cannot exist without the observations it cites. +- **Detectors reach nothing.** A detector is handed an immutable snapshot and no session, socket, subprocess, target, or operator option. A test parses the detection package and fails the build if that stops being true. +- **A finding is checkable.** It names the detector and rule that produced it, the observations behind it, the runs involved, and the SHA-256 of the retained artifact. +- **Ratings are not inflated.** Severity and confidence are separate fields, missing hardening headers are `low`, and there is no risk score, CVSS vector, or aggregate rating, because RedDock does not compute one. +- **CVE data is never invented.** RedDock downloads none. Enrichment is optional, local, exact-match only, and never changes a severity or a status. -Read [SECURITY.md](SECURITY.md) for the authorized-use policy and the full Phase 1 control list. +Read [SECURITY.md](SECURITY.md) for the authorized-use policy and the full control list. ## Repository Structure ```text -backend/ FastAPI API, DockGuard, discovery adapters, evidence, and SQLite persistence +backend/ FastAPI API, DockGuard, discovery adapters, detectors, evidence, and SQLite persistence frontend/ React and TypeScript dashboard scripts/ Local end-to-end smoke test docs/ Architecture decisions and project documentation @@ -133,13 +161,15 @@ docs/ Architecture decisions and project documentation ## Project Status -**v0.2.1 is the current release:** it finalizes Phase 1 with consistent version metadata across the application, API, and packages. +**v0.3.0 delivered Phase 2 — Detection:** the detector contract and registry, detection runs, normalized findings with separate severity and confidence, deduplication by stable fingerprint, a lifecycle that resolves rather than deletes, evidence links from every finding back to the observations and hashes behind it, and the CVE enrichment boundary. + +**v0.2.1 finalized Phase 1** with consistent version metadata across the application, API, and packages. **v0.2.0 delivered Phase 1 — Discovery:** DockGuard scope enforcement, asset/service/observation models, the Nmap and HTTP discovery adapters, discovery-run auditing, and the RedLedger evidence foundation. **v0.1.0 delivered Phase 0 — Foundation:** a containerized React/FastAPI application, local Dockyard persistence, a dashboard, documentation, tests, and CI. -**Next: Phase 2 — Detection.** Normalized findings, detection adapter contracts, CVE enrichment, and deduplication are planned, not implemented. See the [roadmap](ROADMAP.md) for the complete phased plan. +**Next: Phase 3 — Validation.** Controlled non-destructive validation, approval gates, and evidence packages are planned, not implemented. Detection concludes; it does not confirm by attempting. See the [roadmap](ROADMAP.md) for the complete phased plan. ## Contributing and Security diff --git a/ROADMAP.md b/ROADMAP.md index 0669dab..a567e0f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,11 +8,13 @@ Containerized application, React UI shell, FastAPI API, SQLite Dockyards, safety DockGuard scope definitions, asset/service/observation models, the Nmap and HTTP discovery adapters, discovery-run auditing, and the RedLedger evidence foundation. Scoped discovery now produces auditable asset observations with hashed evidence. Released as v0.2.0 and finalized in v0.2.1. -## Next — Phase 2: Detection +## Completed — Phase 2: Detection -Normalized findings, detection adapter contracts, CVE enrichment, and deduplication. Complete when observations can become traceable findings without fabricating data. +Normalized findings, the detector contract and registry, detection runs, deduplication by stable fingerprint, a finding lifecycle that resolves rather than deletes, and the CVE enrichment boundary. Observations now become traceable findings without fabricating data: a finding names the detector and rule that produced it, cites the observations it was drawn from, and carries the hashes that verify them. Released as v0.3.0. -## Phase 3 — Validation +RedDock ships no CVE data. Enrichment is a boundary with a local, operator-supplied catalogue behind it, and a catalogue match is an association rather than a conclusion. See [ADR 0007](docs/adr/0007-cve-enrichment-is-an-association.md). + +## Next — Phase 3: Validation Controlled non-destructive validation, confidence scoring, approval gates, and evidence packages. Complete when validation actions require scope and policy decisions. diff --git a/SECURITY.md b/SECURITY.md index 9c0b7a7..9f2e385 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,13 +4,13 @@ RedDock is for systems owned by the operator or assessed with explicit authorization. It is appropriate for authorized engagements, labs, cyber ranges, CTFs, and training environments. Do not use it to access systems outside approved scope. -From Phase 1 onward RedDock can contact a network target. Scoping a target in RedDock is a statement that you are authorized to assess it. The product enforces the scope you declare; it cannot verify that you were entitled to declare it. +RedDock can contact a network target. Scoping a target in RedDock is a statement that you are authorized to assess it. The product enforces the scope you declare; it cannot verify that you were entitled to declare it. ## Product safety model Every action passes DockGuard before a tool runs, and DockGuard fails closed: anything it cannot positively place inside the Dockyard's authorized scope is denied. AI will never receive unrestricted shell access or the ability to expand target scope. -## Phase 1 safety controls +## Safety controls **Scope enforcement** @@ -36,12 +36,24 @@ Every action passes DockGuard before a tool runs, and DockGuard fails closed: an - Only non-invasive profiles exist. Nmap runs without NSE scripts, brute force, credential guessing, exploit scripts, OS detection, UDP scanning, fragmentation, decoys, spoofing, source-port manipulation, or `-A`. - The HTTP probe issues one request per origin, follows no redirects, reads no response body, and does not crawl, fuzz, submit forms, or test for vulnerabilities. +**Detection** + +- Detection reads only what RedDock already recorded. A detector receives an immutable snapshot of one Dockyard and is given no database session, no socket, no subprocess, no target, and no operator-supplied option, so there is nothing for it to reach, execute, or widen. `tests/test_detection_contract.py` parses the detection package and fails the build if a detector imports anything that could. +- A detection request carries no parameters at all. There is no target field, no detector selection, and no options, so no operator string reaches a detector. +- Detectors are registered explicitly in code. Nothing is loaded from a path, a plugin directory, or configuration, and there is no dynamic import, `eval`, or `exec` anywhere in the detection package. +- Every finding must cite at least one observation from the snapshot it was drawn from. A finding that cites none, names another Dockyard's data, or carries an unknown severity, confidence, or category is refused, and the detector that produced it is failed as a whole rather than partially trusted. +- A detector that fails resolves nothing. Not running is never treated as evidence that an issue went away. +- Findings are never deleted. An issue that a later run no longer reproduces is marked resolved; an operator may suppress, accept, or reopen one but may not declare it resolved. +- Ratings are stated conservatively and separately. Severity and confidence are distinct fields, missing hardening headers are reported as `low`, and RedDock produces no risk score, CVSS vector, or aggregate rating because it does not compute one. +- RedDock downloads no CVE data. Enrichment is off unless an operator supplies a local catalogue, matches only an exact product and version, and never changes a finding's severity, confidence, or status. + **Evidence and data** - Evidence paths are built from integer identifiers and a validated artifact name, and each resolved destination is confirmed to be inside its run directory before a write. - Raw artifacts are capped at 2 MiB and marked when truncated. - Only a small allowlist of response headers is retained; cookies and other session material are never written to evidence. -- Every stored artifact is SHA-256 hashed and recorded. +- Every stored artifact is SHA-256 hashed and recorded, for detection runs as well as discovery runs. +- Every finding is traceable to the observations it was drawn from, the discovery run that recorded them, and the hash of the retained artifact they came from. **Runtime** @@ -50,11 +62,14 @@ Every action passes DockGuard before a tool runs, and DockGuard fails closed: an - Inputs use Pydantic validation; unknown or malformed requests are rejected. - CORS is intentionally not opened because UI and API share one origin. - Concurrent runs and run duration are bounded; a run interrupted by a restart is marked failed rather than left active. +- Detection is bounded too: the snapshot it reads, the findings a detector may return, and the evidence references a finding may carry all have limits, and an operator-supplied CVE catalogue is size- and entry-capped. - No secrets are checked into this repository. ## What RedDock does not do -Phase 1 contains no vulnerability scanning, CVE matching, findings, severity scoring, exploitation, credential testing, injection testing, post-exploitation, attack-path analysis, AI reasoning, automated remediation, or report generation. Observations record what was seen and assign no verdict. +RedDock contains no exploitation, credential testing, brute force, injection testing, payload execution, evasion, persistence, lateral movement, post-exploitation, attack-path analysis, AI reasoning, automated remediation, or report generation. No operator-supplied script or shell command is executed anywhere in the product. + +It also performs no active vulnerability testing. Detection reasons over data an earlier, non-invasive discovery already recorded; it sends nothing, and it confirms nothing by attempting it. A finding therefore states what RedDock concluded from what it saw, not what it proved by trying — which is why a version banner is reported as a disclosure rather than a vulnerability, and why a CVE association from a local catalogue is never a statement that a service is exploitable. Demonstrating that a finding is real, safely and with an approval gate, is Phase 3. ## Reporting a vulnerability @@ -64,7 +79,8 @@ Do not open a public issue for a suspected security flaw. When GitHub Private Vu | Version | Supported | | --- | --- | -| 0.2.x | Yes — current published release | +| 0.3.x | Yes — current published release | +| 0.2.x | No — superseded by 0.3.0 | | 0.1.x | No — superseded by 0.2.0 | Security fixes are evaluated for the latest published release. RedDock is a local, single-operator application in this phase; do not expose it to untrusted networks. diff --git a/backend/README.md b/backend/README.md index 106c9cf..f11f2d8 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # RedDock backend -This package contains RedDock Core's FastAPI application: the API, DockGuard scope enforcement, the discovery adapters, and SQLite persistence. Run it via the repository's Docker Compose workflow, or install it locally for development with Python 3.13. +This package contains RedDock Core's FastAPI application: the API, DockGuard scope enforcement, the discovery adapters, the detectors, and SQLite persistence. Run it via the repository's Docker Compose workflow, or install it locally for development with Python 3.13. ```text app/targets.py target parsing and normalization @@ -8,5 +8,13 @@ app/dockguard.py scope evaluation and decisions app/services.py Dockyard and scope operations app/inventory.py asset, service, and observation persistence rules app/discovery/ adapter contract, adapters, registry, and run orchestration +app/detection/ detector contract, detectors, registry, enrichment, and run orchestration +app/findings.py finding persistence, deduplication, and lifecycle rules app/evidence.py hashed evidence storage ``` + +A discovery adapter may contact a target after DockGuard allows it. A detector +may not contact anything: it is handed a frozen snapshot of one Dockyard and +returns value objects, with no session, socket, subprocess or operator input in +reach. `tests/test_detection_contract.py` reads the detection package and fails +if that stops being true. diff --git a/backend/app/api.py b/backend/app/api.py index 744feed..e094b1c 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -3,25 +3,36 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.config import get_settings from app.database import get_session +from app.detection import registry as detection_registry +from app.detection import runner as detection_runner +from app.detection.base import FindingStatus, Severity from app.discovery import registry from app.discovery import runner as discovery_runner from app.dockguard import Evaluation, ScopeRejected, evaluate, system_resolver +from app.findings import get_finding, list_evidence, list_findings, set_status from app.inventory import get_asset, list_assets, list_observations, list_services -from app.models import Dockyard, EvidenceRecord +from app.models import Asset, Dockyard, EvidenceRecord, Finding, FindingEvidence, Service from app.schemas import ( AdapterRead, AssetDetailRead, AssetRead, + DetectionCreate, + DetectionRunRead, + DetectorRead, DiscoveryCreate, DiscoveryRunRead, DockyardCreate, DockyardRead, EvidenceRecordRead, + FindingDetailRead, + FindingEvidenceRead, + FindingRead, + FindingStatusUpdate, HealthRead, ObservationRead, ProfileRead, @@ -81,6 +92,21 @@ def version() -> VersionRead: return VersionRead(name=settings.app_name, version=settings.version, phase=settings.phase) +@router.get("/detectors", response_model=list[DetectorRead]) +def read_detectors() -> list[DetectorRead]: + """The fixed set of detectors. Nothing is loaded at runtime.""" + return [ + DetectorRead( + id=detector.id, + version=detector.version, + title=detector.title, + description=detector.description, + consumes=list(detector.consumes), + ) + for detector in detection_registry.available_detectors() + ] + + @router.get("/adapters", response_model=list[AdapterRead]) def read_adapters() -> list[AdapterRead]: return [ @@ -277,3 +303,180 @@ def read_evidence( .limit(limit) ) return list(session.scalars(statement)) + + +@router.get("/dockyards/{dockyard_id}/detections", response_model=list[DetectionRunRead]) +def read_detections( + dockyard_id: int, limit: int = ListLimit, session: Session = Depends(get_session) +) -> list[DetectionRunRead]: + require_dockyard(dockyard_id, session) + return detection_runner.list_runs(session, dockyard_id, limit) + + +@router.post( + "/dockyards/{dockyard_id}/detections", + response_model=DetectionRunRead, + status_code=status.HTTP_201_CREATED, +) +def start_detection( + dockyard_id: int, + payload: DetectionCreate, + session: Session = Depends(get_session), +) -> DetectionRunRead: + """Run every registered detector over what this Dockyard already recorded. + + Detection reads stored state and contacts nothing, so it runs to completion + within the request and the response describes a finished run. The request + body is empty by design: there is no target and no operator-supplied option + for a detector to act on. + """ + require_dockyard(dockyard_id, session) + try: + run = detection_runner.start_detection(session, dockyard_id) + except detection_runner.RunRejected as error: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error + return DetectionRunRead.model_validate(run) + + +@router.get("/dockyards/{dockyard_id}/detections/{run_id}", response_model=DetectionRunRead) +def read_detection( + dockyard_id: int, run_id: int, session: Session = Depends(get_session) +) -> DetectionRunRead: + require_dockyard(dockyard_id, session) + run = detection_runner.get_run(session, dockyard_id, run_id) + if run is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Detection run not found") + return run + + +@router.get("/dockyards/{dockyard_id}/findings", response_model=list[FindingRead]) +def read_findings( + dockyard_id: int, + finding_status: FindingStatus | None = Query(default=None, alias="status"), + severity: Severity | None = Query(default=None), + detector: str | None = Query(default=None, max_length=48), + asset_id: int | None = Query(default=None, ge=1), + service_id: int | None = Query(default=None, ge=1), + limit: int = ListLimit, + session: Session = Depends(get_session), +) -> list[FindingRead]: + """Findings for one Dockyard. + + Every filter is validated before it reaches a query, and the Dockyard is + always part of that query: a finding is never reachable from another + workspace. + """ + require_dockyard(dockyard_id, session) + rows = list_findings( + session, + dockyard_id, + limit, + status=str(finding_status) if finding_status else None, + severity=str(severity) if severity else None, + detector=detector, + asset_id=asset_id, + service_id=service_id, + ) + labels = _subject_labels(session, rows) + counts = _evidence_counts(session, rows) + return [_finding_body(FindingRead, finding, labels, counts) for finding in rows] + + +@router.get("/dockyards/{dockyard_id}/findings/{finding_id}", response_model=FindingDetailRead) +def read_finding( + dockyard_id: int, finding_id: int, session: Session = Depends(get_session) +) -> FindingDetailRead: + require_dockyard(dockyard_id, session) + finding = get_finding(session, dockyard_id, finding_id) + if finding is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Finding not found") + evidence = list_evidence(session, finding.id) + labels = _subject_labels(session, [finding]) + detail = _finding_body(FindingDetailRead, finding, labels, {finding.id: len(evidence)}) + return detail.model_copy(update={"evidence": _evidence_bodies(session, evidence)}) + + +@router.patch("/dockyards/{dockyard_id}/findings/{finding_id}", response_model=FindingDetailRead) +def update_finding( + dockyard_id: int, + finding_id: int, + payload: FindingStatusUpdate, + session: Session = Depends(get_session), +) -> FindingDetailRead: + """Record an operator decision about a finding. + + A finding is never deleted here. Suppressing or accepting one keeps it, its + history and its evidence; it only changes what RedDock treats as open. + """ + require_dockyard(dockyard_id, session) + finding = get_finding(session, dockyard_id, finding_id) + if finding is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Finding not found") + set_status(session, finding, payload.status, payload.note) + return read_finding(dockyard_id, finding_id, session) + + +def _subject_labels(session: Session, rows: list[Finding]) -> dict[str, dict[int, str]]: + """Readable asset and service labels for the findings being returned.""" + asset_ids = {finding.asset_id for finding in rows if finding.asset_id} + service_ids = {finding.service_id for finding in rows if finding.service_id} + assets: dict[int, str] = {} + services: dict[int, str] = {} + if asset_ids: + for asset in session.scalars(select(Asset).where(Asset.id.in_(asset_ids))): + assets[asset.id] = asset.display_name + if service_ids: + for service in session.scalars(select(Service).where(Service.id.in_(service_ids))): + services[service.id] = f"{service.transport.upper()}/{service.port}" + return {"assets": assets, "services": services} + + +def _evidence_counts(session: Session, rows: list[Finding]) -> dict[int, int]: + identifiers = [finding.id for finding in rows] + if not identifiers: + return {} + statement = ( + select(FindingEvidence.finding_id, func.count()) + .where(FindingEvidence.finding_id.in_(identifiers)) + .group_by(FindingEvidence.finding_id) + ) + return {finding_id: count for finding_id, count in session.execute(statement)} + + +def _finding_body(model, finding: Finding, labels: dict, counts: dict[int, int]): + body = model.model_validate(finding) + return body.model_copy( + update={ + "asset_label": labels["assets"].get(finding.asset_id), + "service_endpoint": labels["services"].get(finding.service_id), + "evidence_count": counts.get(finding.id, 0), + } + ) + + +def _evidence_bodies( + session: Session, evidence: list[FindingEvidence] +) -> list[FindingEvidenceRead]: + """Evidence rows with the RedLedger artifact and hash behind each one.""" + record_ids = {row.evidence_record_id for row in evidence if row.evidence_record_id} + records: dict[int, EvidenceRecord] = {} + if record_ids: + records = { + record.id: record + for record in session.scalars( + select(EvidenceRecord).where(EvidenceRecord.id.in_(record_ids)) + ) + } + bodies = [] + for row in evidence: + record = records.get(row.evidence_record_id) if row.evidence_record_id else None + body = FindingEvidenceRead.model_validate(row) + bodies.append( + body.model_copy( + update={ + "evidence_path": record.relative_path if record else None, + "sha256": record.sha256 if record else None, + } + ) + ) + return bodies diff --git a/backend/app/config.py b/backend/app/config.py index b434621..44a4c3a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,13 +9,16 @@ class Settings(BaseModel): """Runtime settings kept intentionally small for the local foundation.""" app_name: str = "RedDock" - version: str = "0.2.1" - phase: str = "Phase 1 — Discovery" + version: str = "0.3.0" + phase: str = "Phase 2 — Detection" database_url: str = "sqlite:///./data/reddock.db" evidence_dir: str = "./data/evidence" nmap_path: str | None = None + # Optional, local, and off unless an operator supplies it. RedDock never + # downloads CVE data; see app/detection/enrichment.py. + cve_catalog_path: str | None = None - # Phase 1 safety bounds. These are constants rather than environment + # Scope and execution bounds. These are constants rather than environment # settings because relaxing them would weaken the exact guarantees # DockGuard exists to provide; an operator who needs a wider engagement # adds more narrow scope entries instead of one broad one. @@ -26,6 +29,17 @@ class Settings(BaseModel): max_evidence_bytes: int = 2 * 1024 * 1024 max_resolved_addresses: int = 4 + # Phase 2 detection bounds. Detection only reads what RedDock already + # stored, so these bound work rather than reach: a snapshot cannot grow + # without limit, a detector cannot flood the findings table, and a finding + # cannot drag an unbounded number of evidence links behind it. + max_detection_assets: int = 2_000 + max_detection_observations: int = 20_000 + max_findings_per_detector: int = 500 + max_evidence_per_finding: int = 20 + max_cve_catalog_bytes: int = 5 * 1024 * 1024 + max_cve_catalog_entries: int = 20_000 + @lru_cache def get_settings() -> Settings: @@ -37,4 +51,5 @@ def get_settings() -> Settings: database_url=database_url, evidence_dir=os.getenv("REDDOCK_EVIDENCE_DIR", defaults.evidence_dir), nmap_path=os.getenv("REDDOCK_NMAP_PATH") or None, + cve_catalog_path=os.getenv("REDDOCK_CVE_CATALOG") or None, ) diff --git a/backend/app/database.py b/backend/app/database.py index eece091..01cece8 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -46,8 +46,8 @@ def get_session() -> Iterator[Session]: def initialize_database() -> None: - # Import before metadata creation so every model is registered. Phase 1 only - # adds tables, so an existing Phase 0 database upgrades in place. + # Import before metadata creation so every model is registered. Every phase + # so far only adds tables, so a deployed database upgrades in place. from app import models # noqa: F401 Base.metadata.create_all(bind=engine) diff --git a/backend/app/detection/__init__.py b/backend/app/detection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/detection/base.py b/backend/app/detection/base.py new file mode 100644 index 0000000..0828fc1 --- /dev/null +++ b/backend/app/detection/base.py @@ -0,0 +1,282 @@ +"""The detection contract. + +A detector turns observations into findings. It is deliberately weaker than a +discovery adapter: an adapter may contact a target, a detector may not. A +detector is handed an immutable snapshot of what a Dockyard already knows and +returns value objects. It never receives a database session, a socket, a +subprocess, a target string or an operator-supplied option, so there is nothing +for it to widen, execute or reach. That is enforced structurally rather than by +convention, and tests/test_detection_contract.py asserts it. + + snapshot -> detect -> validate -> normalize -> findings + +Validation and normalization belong to the runner rather than the detector: a +detector that returns something malformed is treated as failed and its results +are discarded, rather than partially trusted. +""" + +from abc import ABC, abstractmethod +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime +from enum import StrEnum +from types import MappingProxyType + +EMPTY_DETAIL: Mapping[str, object] = MappingProxyType({}) + + +class Severity(StrEnum): + """How much this would matter if it is true.""" + + INFORMATIONAL = "informational" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class FindingConfidence(StrEnum): + """How sure RedDock is that it is true. + + HIGH RedDock observed the behaviour itself and the rule is unambiguous. + MEDIUM The conclusion rests on something the target reported about itself. + LOW The conclusion rests on an inference RedDock could not check. + """ + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class FindingStatus(StrEnum): + """The small lifecycle a finding is allowed to have. + + OPEN Reproduced by the most recent successful run of its detector. + RESOLVED Not reproduced by a later successful run of the same detector. + Set by RedDock, never by an operator, and never by deletion. + SUPPRESSED An operator decided this is noise. It stays out of the open set + even when it is reproduced again. + ACCEPTED An operator decided this is a known and accepted condition. + """ + + OPEN = "open" + RESOLVED = "resolved" + SUPPRESSED = "suppressed" + ACCEPTED = "accepted" + + +class FindingCategory(StrEnum): + TRANSPORT = "transport" + HARDENING = "hardening" + INFORMATION_DISCLOSURE = "information_disclosure" + + +class DetectionRunStatus(StrEnum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + PARTIAL = "partial" + FAILED = "failed" + + +#: Statuses an operator may set. `resolved` is missing on purpose: whether an +#: issue is still reproduced is a fact about the data, not an opinion about it. +OPERATOR_STATUSES: tuple[FindingStatus, ...] = ( + FindingStatus.OPEN, + FindingStatus.SUPPRESSED, + FindingStatus.ACCEPTED, +) + +#: Statuses an operator has taken responsibility for, which automatic +#: resolution therefore leaves alone. +OPERATOR_OWNED_STATUSES: tuple[FindingStatus, ...] = ( + FindingStatus.SUPPRESSED, + FindingStatus.ACCEPTED, +) + + +class DetectorError(RuntimeError): + """Raised when a detector cannot complete.""" + + +@dataclass(frozen=True, slots=True) +class ServiceView: + """A read-only view of one recorded service.""" + + id: int + asset_id: int + transport: str + port: int + state: str + service_name: str | None + product: str | None + version: str | None + first_seen: datetime + last_seen: datetime + + @property + def endpoint(self) -> str: + return f"{self.transport.upper()}/{self.port}" + + +@dataclass(frozen=True, slots=True) +class AssetView: + """A read-only view of one recorded asset and its services.""" + + id: int + asset_type: str + identity: str + display_name: str + ip_address: str | None + hostname: str | None + first_seen: datetime + last_seen: datetime + services: tuple[ServiceView, ...] = () + + +@dataclass(frozen=True, slots=True) +class ObservationView: + """A read-only view of one recorded observation. + + `detail` is a read-only mapping so that a detector cannot mutate the + snapshot it shares with every other detector in the run. + """ + + id: int + discovery_run_id: int | None + asset_id: int | None + service_id: int | None + adapter: str + observation_type: str + summary: str + confidence: str + observed_at: datetime + detail: Mapping[str, object] = EMPTY_DETAIL + + +@dataclass(frozen=True, slots=True) +class CveReference: + """One catalogue association for an observed product and version. + + This is enrichment. It records that a catalogue entry matched, where the + entry came from and how exact the match was. It is never a statement that + the service is exploitable. + """ + + cve_id: str + source: str + match_type: str + matched_product: str + matched_version: str + source_version: str | None = None + url: str | None = None + + def document(self) -> dict[str, str | None]: + return { + "cve_id": self.cve_id, + "source": self.source, + "source_version": self.source_version, + "match_type": self.match_type, + "matched_product": self.matched_product, + "matched_version": self.matched_version, + "url": self.url, + } + + +class Enrichment(ABC): + """The boundary behind which optional catalogue data is looked up.""" + + id: str + version: str | None + available: bool + + @abstractmethod + def lookup(self, product: str, version: str) -> tuple[CveReference, ...]: + """Return catalogue associations for an exactly matching product and version.""" + + +@dataclass(frozen=True, slots=True) +class DetectionContext: + """Everything a detector is permitted to know. + + It is a snapshot of already-recorded state. There is no session, no + connection and no target here, which is what makes a detector unable to + reach anything. + """ + + dockyard_id: int + generated_at: datetime + assets: tuple[AssetView, ...] = () + observations: tuple[ObservationView, ...] = () + enrichment: Enrichment | None = None + + def asset(self, asset_id: int | None) -> AssetView | None: + if asset_id is None: + return None + return next((asset for asset in self.assets if asset.id == asset_id), None) + + def service(self, service_id: int | None) -> ServiceView | None: + if service_id is None: + return None + for asset in self.assets: + for service in asset.services: + if service.id == service_id: + return service + return None + + def of_type(self, *observation_types: str) -> tuple[ObservationView, ...]: + """Observations of the given types, oldest first.""" + wanted = frozenset(observation_types) + return tuple( + observation + for observation in self.observations + if observation.observation_type in wanted + ) + + def enrich(self, product: str | None, version: str | None) -> tuple[CveReference, ...]: + """Look up catalogue associations, tolerating an absent catalogue.""" + if self.enrichment is None or not product or not version: + return () + return self.enrichment.lookup(product, version) + + +@dataclass(frozen=True, slots=True) +class DetectedFinding: + """What a detector concluded, before RedDock validates and stores it. + + `evidence_observation_ids` is not optional in practice: the runner refuses a + finding that cites no observation from the snapshot it was given. + """ + + rule_id: str + title: str + description: str + category: FindingCategory + severity: Severity + confidence: FindingConfidence + evidence_observation_ids: tuple[int, ...] + asset_id: int | None = None + service_id: int | None = None + #: A stable discriminator for a rule that can fire more than once on the + #: same service. It is part of the fingerprint, so it must be derived from + #: the data and never from a clock, a counter or a random value. + scope_key: str = "" + remediation: str | None = None + detail: dict = field(default_factory=dict) + cve_references: tuple[CveReference, ...] = () + + +class Detector(ABC): + """Base class for every detector RedDock is allowed to run.""" + + id: str + version: str + title: str + description: str + #: The observation types and inventory facts this detector reads, declared + #: so a reviewer can see what it consumes without reading its body. + consumes: tuple[str, ...] + + @abstractmethod + def detect(self, context: DetectionContext) -> tuple[DetectedFinding, ...]: + """Inspect the snapshot and return zero or more findings.""" diff --git a/backend/app/detection/context.py b/backend/app/detection/context.py new file mode 100644 index 0000000..b6d544d --- /dev/null +++ b/backend/app/detection/context.py @@ -0,0 +1,128 @@ +"""Building the immutable snapshot a detection run reasons over. + +This module is the only place where database rows become detector input. It +reads one Dockyard, converts rows into frozen views and hands those views on, so +a detector holds no session, no identity from another Dockyard and nothing it +can write through. +""" + +from datetime import UTC, datetime +from types import MappingProxyType + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.detection.base import ( + AssetView, + DetectionContext, + Enrichment, + ObservationView, + ServiceView, +) +from app.models import Asset, Observation, Service + + +def build_context( + session: Session, + dockyard_id: int, + *, + enrichment: Enrichment | None = None, + generated_at: datetime | None = None, +) -> DetectionContext: + """Snapshot one Dockyard's recorded state, bounded by the Phase 2 limits.""" + settings = get_settings() + assets = _assets(session, dockyard_id, settings.max_detection_assets) + observations = _observations(session, dockyard_id, settings.max_detection_observations) + return DetectionContext( + dockyard_id=dockyard_id, + generated_at=generated_at or datetime.now(UTC), + assets=assets, + observations=observations, + enrichment=enrichment, + ) + + +def _assets(session: Session, dockyard_id: int, limit: int) -> tuple[AssetView, ...]: + rows = list( + session.scalars( + select(Asset) + .where(Asset.dockyard_id == dockyard_id) + .order_by(Asset.id) + .limit(limit) + ) + ) + if not rows: + return () + + services: dict[int, list[ServiceView]] = {} + asset_ids = [asset.id for asset in rows] + for service in session.scalars( + select(Service).where(Service.asset_id.in_(asset_ids)).order_by(Service.id) + ): + services.setdefault(service.asset_id, []).append( + ServiceView( + id=service.id, + asset_id=service.asset_id, + transport=service.transport, + port=service.port, + state=service.state, + service_name=service.service_name, + product=service.product, + version=service.version, + first_seen=_utc(service.first_seen), + last_seen=_utc(service.last_seen), + ) + ) + + return tuple( + AssetView( + id=asset.id, + asset_type=asset.asset_type, + identity=asset.identity, + display_name=asset.display_name, + ip_address=asset.ip_address, + hostname=asset.hostname, + first_seen=_utc(asset.first_seen), + last_seen=_utc(asset.last_seen), + services=tuple(services.get(asset.id, ())), + ) + for asset in rows + ) + + +def _observations(session: Session, dockyard_id: int, limit: int) -> tuple[ObservationView, ...]: + """The most recent observations, returned oldest first. + + The limit takes the newest rows because a detector reasons about the current + state of a Dockyard, but the result is ordered oldest first so that "the + latest observation of this kind" is simply the last one. + """ + newest = list( + session.scalars( + select(Observation) + .where(Observation.dockyard_id == dockyard_id) + .order_by(Observation.id.desc()) + .limit(limit) + ) + ) + return tuple( + ObservationView( + id=observation.id, + discovery_run_id=observation.discovery_run_id, + asset_id=observation.asset_id, + service_id=observation.service_id, + adapter=observation.adapter, + observation_type=observation.observation_type, + summary=observation.summary, + confidence=observation.confidence, + observed_at=_utc(observation.observed_at), + detail=MappingProxyType(dict(observation.detail or {})), + ) + for observation in reversed(newest) + ) + + +def _utc(moment: datetime) -> datetime: + """Timestamps are stored in UTC; detectors are given aware values.""" + return moment if moment.tzinfo else moment.replace(tzinfo=UTC) diff --git a/backend/app/detection/detectors/__init__.py b/backend/app/detection/detectors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/detection/detectors/http_headers.py b/backend/app/detection/detectors/http_headers.py new file mode 100644 index 0000000..b6cb0ed --- /dev/null +++ b/backend/app/detection/detectors/http_headers.py @@ -0,0 +1,374 @@ +"""HTTP security-header detector. + +This detector reads the response RedDock already recorded for an origin and +reports the response-level protections that were not in place. It contacts +nothing and re-requests nothing. + +Three things keep it from producing the usual noise: + +- It only speaks about headers the probe actually examined. Every response + observation records the header set that was looked for, and a header outside + that set produces no finding, because "RedDock did not look" is not the same + statement as "the server did not send it". +- It accounts for the scheme. Strict-Transport-Security is only meaningful over + HTTPS, so its absence over plaintext HTTP is not reported as a gap; the + plaintext transport itself is the finding there. +- It only judges a response that represents how the endpoint normally answers. + Redirects and server errors are skipped for content-level headers, because a + 301 to HTTPS carrying no Content-Security-Policy is a correct configuration + and reporting it would be a false positive. + +Severity is deliberately restrained. A missing hardening header is a +defence-in-depth gap, not a demonstrated weakness, so these are `low` with high +confidence rather than the inflated ratings that make a findings list useless. +""" + +from collections.abc import Mapping, Sequence + +from app.detection.base import ( + DetectedFinding, + DetectionContext, + Detector, + FindingCategory, + FindingConfidence, + ObservationView, + Severity, +) + +HSTS = "strict-transport-security" +CONTENT_TYPE_OPTIONS = "x-content-type-options" +CONTENT_SECURITY_POLICY = "content-security-policy" +FRAME_OPTIONS = "x-frame-options" +LOCATION = "location" + +_NOSNIFF = "nosniff" +_FRAME_ANCESTORS = "frame-ancestors" + + +class HttpSecurityHeaderDetector(Detector): + id = "http.security_headers" + version = "1.0.0" + title = "HTTP security headers" + description = ( + "Reports response-level protections that the recorded HTTP response did not carry, " + "for the headers the probe examined." + ) + consumes = ("http_response", "http_header") + + def detect(self, context: DetectionContext) -> tuple[DetectedFinding, ...]: + findings: list[DetectedFinding] = [] + for response in _latest_responses(context): + findings.extend(self._for_response(context, response)) + return tuple(findings) + + def _for_response( + self, context: DetectionContext, response: ObservationView + ) -> list[DetectedFinding]: + examined = _examined(response) + if not examined: + # A response recorded before RedDock stated what it looked for + # cannot support an absence claim. + return [] + + scheme = _scheme(context, response) + if scheme is None: + return [] + status = response.detail.get("status") + if not isinstance(status, int): + return [] + + headers = _headers_for(context, response) + origin = _origin(context, response) + findings: list[DetectedFinding] = [] + + if scheme == "http": + finding = self._plaintext(response, headers, status, origin) + if finding is not None: + findings.append(finding) + elif HSTS in examined and HSTS not in headers: + findings.append(self._missing_hsts(response, status, origin)) + + if not _is_content_response(status): + return findings + + if CONTENT_TYPE_OPTIONS in examined: + finding = self._content_type_options(response, headers, status, origin) + if finding is not None: + findings.append(finding) + if CONTENT_SECURITY_POLICY in examined and CONTENT_SECURITY_POLICY not in headers: + findings.append(self._missing_csp(response, status, origin)) + if FRAME_OPTIONS in examined: + finding = self._frame_protection(response, headers, status, origin) + if finding is not None: + findings.append(finding) + return findings + + def _plaintext( + self, + response: ObservationView, + headers: Mapping[str, tuple[str, int]], + status: int, + origin: str, + ) -> DetectedFinding | None: + location = headers.get(LOCATION) + if 300 <= status < 400 and location and location[0].lower().startswith("https://"): + # A redirect to HTTPS is the correct answer on a plaintext port. + return None + return self._finding( + response, + headers, + rule_id="plaintext-http", + title=f"{origin} answers over plaintext HTTP", + description=( + f"RedDock completed an HTTP exchange with {origin} over plaintext and received " + f"HTTP {status}. Anything sent to this origin, including credentials and session " + "cookies, travels the network unprotected and can be read or altered in transit. " + "This severity describes the transport itself and does not account for how " + "exposed the network path is." + ), + category=FindingCategory.TRANSPORT, + severity=Severity.MEDIUM, + confidence=FindingConfidence.HIGH, + remediation=( + "Serve this origin over HTTPS and redirect plaintext requests to it, then set " + "Strict-Transport-Security on the HTTPS origin." + ), + status=status, + scheme="http", + related=(LOCATION,), + ) + + def _missing_hsts( + self, response: ObservationView, status: int, origin: str + ) -> DetectedFinding: + return self._finding( + response, + {}, + rule_id="hsts-not-set", + title=f"{origin} does not set Strict-Transport-Security", + description=( + f"The HTTPS response from {origin} (HTTP {status}) carried no " + "Strict-Transport-Security header. Without it a browser will still attempt a " + "plaintext request to this origin, which leaves the first request of a session " + "open to interception or downgrade." + ), + category=FindingCategory.HARDENING, + severity=Severity.LOW, + confidence=FindingConfidence.HIGH, + remediation=( + "Send Strict-Transport-Security with a max-age the operator is prepared to " + "commit to, once every path on the origin is served over HTTPS." + ), + status=status, + scheme="https", + ) + + def _content_type_options( + self, + response: ObservationView, + headers: Mapping[str, tuple[str, int]], + status: int, + origin: str, + ) -> DetectedFinding | None: + present = headers.get(CONTENT_TYPE_OPTIONS) + if present is not None and present[0].strip().lower() == _NOSNIFF: + return None + observed = ( + f"sent X-Content-Type-Options: {present[0]}" + if present is not None + else "sent no X-Content-Type-Options header" + ) + return self._finding( + response, + headers, + rule_id="content-type-options-not-nosniff", + title=f"{origin} does not set X-Content-Type-Options: nosniff", + description=( + f"The response from {origin} (HTTP {status}) {observed}. A browser may then " + "infer a content type other than the one declared, so a response intended as " + "data can be treated as script or markup." + ), + category=FindingCategory.HARDENING, + severity=Severity.LOW, + confidence=FindingConfidence.HIGH, + remediation="Send X-Content-Type-Options: nosniff on every response.", + status=status, + scheme=None, + related=(CONTENT_TYPE_OPTIONS,), + ) + + def _missing_csp(self, response: ObservationView, status: int, origin: str) -> DetectedFinding: + return self._finding( + response, + {}, + rule_id="content-security-policy-not-set", + title=f"{origin} does not set a Content-Security-Policy", + description=( + f"The response from {origin} (HTTP {status}) carried no Content-Security-Policy " + "header. The browser therefore applies no restriction on where scripts, styles " + "and frames may be loaded from, which removes a control that limits the impact " + "of an injection flaw elsewhere in the application. RedDock has not tested this " + "origin for injection flaws." + ), + category=FindingCategory.HARDENING, + severity=Severity.LOW, + confidence=FindingConfidence.HIGH, + remediation=( + "Define a Content-Security-Policy for this origin, starting in report-only mode " + "so the policy can be validated before it is enforced." + ), + status=status, + scheme=None, + ) + + def _frame_protection( + self, + response: ObservationView, + headers: Mapping[str, tuple[str, int]], + status: int, + origin: str, + ) -> DetectedFinding | None: + if FRAME_OPTIONS in headers: + return None + policy = headers.get(CONTENT_SECURITY_POLICY) + if policy is not None and _FRAME_ANCESTORS in policy[0].lower(): + # Content-Security-Policy frame-ancestors supersedes X-Frame-Options. + return None + return self._finding( + response, + headers, + rule_id="frame-protection-not-set", + title=f"{origin} does not restrict framing", + description=( + f"The response from {origin} (HTTP {status}) carried neither an X-Frame-Options " + "header nor a Content-Security-Policy frame-ancestors directive, so another " + "site may embed this origin in a frame." + ), + category=FindingCategory.HARDENING, + severity=Severity.LOW, + confidence=FindingConfidence.HIGH, + remediation=( + "Add a Content-Security-Policy frame-ancestors directive naming the origins " + "allowed to frame this one, or none at all." + ), + status=status, + scheme=None, + related=(CONTENT_SECURITY_POLICY,), + ) + + def _finding( + self, + response: ObservationView, + headers: Mapping[str, tuple[str, int]], + *, + rule_id: str, + title: str, + description: str, + category: FindingCategory, + severity: Severity, + confidence: FindingConfidence, + remediation: str, + status: int, + scheme: str | None, + related: Sequence[str] = (), + ) -> DetectedFinding: + evidence = [response.id] + detail: dict[str, object] = { + "status": status, + "discovery_run_id": response.discovery_run_id, + } + if scheme is not None: + detail["scheme"] = scheme + for header in related: + found = headers.get(header) + if found is None: + continue + detail[header] = found[0] + evidence.append(found[1]) + return DetectedFinding( + rule_id=rule_id, + title=title, + description=description, + category=category, + severity=severity, + confidence=confidence, + evidence_observation_ids=tuple(dict.fromkeys(evidence)), + asset_id=response.asset_id, + service_id=response.service_id, + remediation=remediation, + detail=detail, + ) + + +def _latest_responses(context: DetectionContext) -> list[ObservationView]: + """The most recent recorded response for each endpoint, in a stable order. + + Observations accumulate as history, so an endpoint probed three times has + three responses. Only the newest describes how it answers now. + """ + latest: dict[tuple[int | None, int | None], ObservationView] = {} + for observation in context.of_type("http_response"): + key = (observation.asset_id, observation.service_id) + current = latest.get(key) + if current is None or (observation.observed_at, observation.id) >= ( + current.observed_at, + current.id, + ): + latest[key] = observation + return sorted(latest.values(), key=lambda observation: observation.id) + + +def _headers_for( + context: DetectionContext, response: ObservationView +) -> dict[str, tuple[str, int]]: + """Header values recorded by the same discovery run, with their observation.""" + headers: dict[str, tuple[str, int]] = {} + for observation in context.of_type("http_header"): + if observation.discovery_run_id != response.discovery_run_id: + continue + if (observation.asset_id, observation.service_id) != ( + response.asset_id, + response.service_id, + ): + continue + name = observation.detail.get("header") + value = observation.detail.get("value") + if isinstance(name, str) and isinstance(value, str): + headers[name.lower()] = (value, observation.id) + return headers + + +def _is_content_response(status: int) -> bool: + """Whether this response represents how the endpoint normally answers. + + A redirect is a routing instruction and a server error is a failure; neither + is the application's normal response, so neither is evidence that a + content-level header is missing from it. + """ + return status < 300 or 400 <= status < 500 + + +def _examined(response: ObservationView) -> frozenset[str]: + raw = response.detail.get("headers_examined") + if not isinstance(raw, list): + return frozenset() + return frozenset(item.lower() for item in raw if isinstance(item, str)) + + +def _scheme(context: DetectionContext, response: ObservationView) -> str | None: + """The scheme of the exchange, taken from the response or the origin.""" + recorded = response.detail.get("scheme") + if recorded in ("http", "https"): + return str(recorded) + asset = context.asset(response.asset_id) + if asset is None: + return None + for scheme in ("https", "http"): + if asset.identity.startswith(f"{scheme}://"): + return scheme + return None + + +def _origin(context: DetectionContext, response: ObservationView) -> str: + asset = context.asset(response.asset_id) + return asset.identity if asset is not None else "this origin" diff --git a/backend/app/detection/detectors/service_rules.py b/backend/app/detection/detectors/service_rules.py new file mode 100644 index 0000000..7f1c2fc --- /dev/null +++ b/backend/app/detection/detectors/service_rules.py @@ -0,0 +1,222 @@ +"""Service rule detector. + +A small, explicit table of rules over services RedDock actually identified. It +does not guess from a port number: the inventory only carries a service name, +product or version when an adapter probed for it and the target answered, and +this detector additionally requires the identification observation itself as +evidence. A service with no identification observation produces no finding. + +Two kinds of rule live here. + +Protocol rules state something true of the protocol itself, which is why they +are defensible without a version database: Telnet has no transport encryption, +and FTP's control channel is cleartext unless the server requires AUTH TLS. +RedDock cannot see whether AUTH TLS is required, so the FTP rule is reported at +medium confidence and says so, rather than asserting a fact it did not check. + +The version rule is the anchor for CVE enrichment. It records that a service +disclosed a product and version, at informational severity, because disclosure +is not a weakness. Any CVE identifiers a local catalogue associates with that +exact product and version are attached to it as references. They never change +its severity, and they are never a statement that this service is exploitable. +""" + +from dataclasses import dataclass + +from app.detection.base import ( + AssetView, + DetectedFinding, + DetectionContext, + Detector, + FindingCategory, + FindingConfidence, + ObservationView, + ServiceView, + Severity, +) + +IDENTIFICATION = "service_identified" +_OPEN_STATES = frozenset({"open", "open|filtered"}) + + +@dataclass(frozen=True, slots=True) +class ProtocolRule: + """One deterministic statement about an identified protocol.""" + + service_name: str + rule_id: str + title: str + description: str + severity: Severity + confidence: FindingConfidence + remediation: str + + +#: Ordered so that detection output does not depend on dictionary insertion. +PROTOCOL_RULES: tuple[ProtocolRule, ...] = ( + ProtocolRule( + service_name="telnet", + rule_id="cleartext-remote-administration", + title="Telnet is reachable on {endpoint}", + description=( + "{asset} answered on {endpoint} as Telnet{identified}. Telnet has no transport " + "encryption at all, so the credentials used to log in and everything typed during " + "the session cross the network in the clear and can be read or altered by anything " + "on the path." + ), + severity=Severity.HIGH, + confidence=FindingConfidence.MEDIUM, + remediation=( + "Replace Telnet with SSH and close the Telnet port once no client depends on it." + ), + ), + ProtocolRule( + service_name="ftp", + rule_id="cleartext-file-transfer", + title="FTP is reachable on {endpoint}", + description=( + "{asset} answered on {endpoint} as FTP{identified}. FTP authenticates over a " + "cleartext control channel unless the server requires AUTH TLS. RedDock did not " + "authenticate and did not test whether AUTH TLS is required, so this reports an " + "exposed FTP service rather than confirmed cleartext authentication." + ), + severity=Severity.MEDIUM, + confidence=FindingConfidence.MEDIUM, + remediation=( + "Require FTPS or replace the service with SFTP, and confirm the server refuses " + "an unencrypted login." + ), + ), +) + +VERSION_DISCLOSURE = "service-version-disclosed" + + +class ServiceRuleDetector(Detector): + id = "service.rules" + version = "1.0.0" + title = "Service rules" + description = ( + "Applies a fixed table of protocol rules to services RedDock identified, and records " + "disclosed product versions as the anchor for optional CVE enrichment." + ) + consumes = ("service_identified", "service inventory") + + def detect(self, context: DetectionContext) -> tuple[DetectedFinding, ...]: + identifications = _identifications(context) + findings: list[DetectedFinding] = [] + for asset in context.assets: + for service in asset.services: + if service.state not in _OPEN_STATES: + continue + observation = identifications.get(service.id) + if observation is None: + # Nothing identified this service, so there is nothing to + # conclude and nothing to prove it with. + continue + findings.extend(self._for_service(context, asset, service, observation)) + return tuple(findings) + + def _for_service( + self, + context: DetectionContext, + asset: AssetView, + service: ServiceView, + observation: ObservationView, + ) -> list[DetectedFinding]: + findings = [] + name = (service.service_name or "").strip().lower() + for rule in PROTOCOL_RULES: + if name == rule.service_name: + findings.append(self._protocol(rule, asset, service, observation)) + if service.product and service.version: + findings.append(self._version(context, asset, service, observation)) + return findings + + def _protocol( + self, + rule: ProtocolRule, + asset: AssetView, + service: ServiceView, + observation: ObservationView, + ) -> DetectedFinding: + identified = f" ({service.product} {service.version})".rstrip() if service.product else "" + return DetectedFinding( + rule_id=rule.rule_id, + title=rule.title.format(endpoint=service.endpoint, asset=asset.display_name), + description=rule.description.format( + asset=asset.display_name, endpoint=service.endpoint, identified=identified + ), + category=FindingCategory.TRANSPORT, + severity=rule.severity, + confidence=rule.confidence, + evidence_observation_ids=(observation.id,), + asset_id=asset.id, + service_id=service.id, + remediation=rule.remediation, + detail={ + "service_name": service.service_name, + "product": service.product, + "version": service.version, + "discovery_run_id": observation.discovery_run_id, + }, + ) + + def _version( + self, + context: DetectionContext, + asset: AssetView, + service: ServiceView, + observation: ObservationView, + ) -> DetectedFinding: + references = context.enrich(service.product, service.version) + association = "" + if references: + catalogue = references[0].source + association = ( + f" A local catalogue ({catalogue}) associates this exact product and version with " + f"{len(references)} published CVE identifier(s). That is an association drawn " + "from a version string the service reported about itself, not a test result: " + "RedDock did not check whether this service is affected or exploitable." + ) + return DetectedFinding( + rule_id=VERSION_DISCLOSURE, + title=f"{service.endpoint} on {asset.display_name} discloses its version", + description=( + f"{asset.display_name} identified the service on {service.endpoint} as " + f"{service.product} {service.version}. A version banner is useful to a reviewer " + "and equally useful to anyone else who can reach the port, because it narrows " + f"down what to try.{association}" + ), + category=FindingCategory.INFORMATION_DISCLOSURE, + severity=Severity.INFORMATIONAL, + confidence=FindingConfidence.MEDIUM, + evidence_observation_ids=(observation.id,), + asset_id=asset.id, + service_id=service.id, + remediation=( + "Decide whether this endpoint needs to publish its version. If it does not, " + "suppress the banner; if it does, keep the version current." + ), + detail={ + "product": service.product, + "version": service.version, + "discovery_run_id": observation.discovery_run_id, + }, + cve_references=references, + ) + + +def _identifications(context: DetectionContext) -> dict[int, ObservationView]: + """The most recent identification observation for each service.""" + latest: dict[int, ObservationView] = {} + for observation in context.of_type(IDENTIFICATION): + if observation.service_id is None: + continue + current = latest.get(observation.service_id) + if current is None or (observation.observed_at, observation.id) >= ( + current.observed_at, + current.id, + ): + latest[observation.service_id] = observation + return latest diff --git a/backend/app/detection/detectors/tls_certificates.py b/backend/app/detection/detectors/tls_certificates.py new file mode 100644 index 0000000..fc4185d --- /dev/null +++ b/backend/app/detection/detectors/tls_certificates.py @@ -0,0 +1,135 @@ +"""TLS certificate detector. + +This detector reads the TLS session RedDock already recorded and reports what +certificate verification objected to. It performs no handshake of its own and no +cipher or protocol enumeration. + +The scope is deliberately narrow, because the honest scope is narrow. The HTTP +probe connects with a default client, so it can only ever record a protocol +version that a current client was willing to negotiate; a rule about obsolete +protocol versions would therefore never be able to fire from RedDock's own data, +and shipping one would suggest a capability that does not exist. What RedDock +does establish is the verification outcome, so that is what this detector +reports. +""" + +from app.detection.base import ( + DetectedFinding, + DetectionContext, + Detector, + FindingCategory, + FindingConfidence, + ObservationView, + Severity, +) + +#: OpenSSL X509_V_ERR_CERT_HAS_EXPIRED. A verification failure is generic until +#: the code says which check failed, so the code is what the rules key on. +CERT_HAS_EXPIRED = 10 + + +class TlsCertificateDetector(Detector): + id = "tls.certificates" + version = "1.0.0" + title = "TLS certificate validation" + description = ( + "Reports the outcome of certificate verification for TLS sessions RedDock recorded." + ) + consumes = ("tls_session",) + + def detect(self, context: DetectionContext) -> tuple[DetectedFinding, ...]: + findings = [] + for session in _latest_sessions(context): + finding = self._for_session(context, session) + if finding is not None: + findings.append(finding) + return tuple(findings) + + def _for_session( + self, context: DetectionContext, session: ObservationView + ) -> DetectedFinding | None: + verified = session.detail.get("verified") + if verified is not False: + # Either verification succeeded, or the record predates RedDock + # stating the outcome. Neither supports a claim. + return None + + origin = _origin(context, session) + code = session.detail.get("verify_code") + message = session.detail.get("verify_message") + reason = str(message) if isinstance(message, str) and message else None + detail: dict[str, object] = { + "verify_code": code if isinstance(code, int) else None, + "verify_message": reason, + "tls_version": session.detail.get("version"), + "certificate_sha256": session.detail.get("certificate_sha256"), + "discovery_run_id": session.discovery_run_id, + } + + if code == CERT_HAS_EXPIRED: + return DetectedFinding( + rule_id="certificate-expired", + title=f"{origin} presents an expired TLS certificate", + description=( + f"Certificate verification for {origin} failed because the certificate has " + "expired. A client that checks certificates will refuse this endpoint or " + "warn about it, and operators who click past that warning lose the " + "protection the certificate was there to provide." + ), + category=FindingCategory.TRANSPORT, + severity=Severity.MEDIUM, + confidence=FindingConfidence.HIGH, + evidence_observation_ids=(session.id,), + asset_id=session.asset_id, + service_id=session.service_id, + remediation=( + "Reissue the certificate for this endpoint and renew it automatically " + "before expiry." + ), + detail=detail, + ) + + explanation = f' OpenSSL reported: "{reason}".' if reason else "" + return DetectedFinding( + rule_id="certificate-not-trusted", + title=f"{origin} presents a certificate that did not verify", + description=( + f"RedDock completed a TLS handshake with {origin}, but the certificate did not " + f"verify against the trust store in the RedDock container.{explanation} A private " + "certificate authority, a self-signed lab certificate or a name that does not " + "match the endpoint are all legitimate causes, so this reports a verification " + "outcome rather than a defect. The certificate SHA-256 is retained as evidence " + "so the same certificate can be recognised later." + ), + category=FindingCategory.TRANSPORT, + severity=Severity.LOW, + confidence=FindingConfidence.HIGH, + evidence_observation_ids=(session.id,), + asset_id=session.asset_id, + service_id=session.service_id, + remediation=( + "Confirm the certificate is the one intended for this endpoint. If a private " + "authority issued it, add that authority to the trust store used to assess it; " + "otherwise issue a certificate that a client can verify." + ), + detail=detail, + ) + + +def _latest_sessions(context: DetectionContext) -> list[ObservationView]: + """The most recent recorded TLS session for each endpoint, in a stable order.""" + latest: dict[tuple[int | None, int | None], ObservationView] = {} + for observation in context.of_type("tls_session"): + key = (observation.asset_id, observation.service_id) + current = latest.get(key) + if current is None or (observation.observed_at, observation.id) >= ( + current.observed_at, + current.id, + ): + latest[key] = observation + return sorted(latest.values(), key=lambda observation: observation.id) + + +def _origin(context: DetectionContext, session: ObservationView) -> str: + asset = context.asset(session.asset_id) + return asset.identity if asset is not None else "this endpoint" diff --git a/backend/app/detection/enrichment.py b/backend/app/detection/enrichment.py new file mode 100644 index 0000000..f55f19c --- /dev/null +++ b/backend/app/detection/enrichment.py @@ -0,0 +1,194 @@ +"""CVE enrichment: the boundary, and the smallest honest implementation. + +RedDock does not fetch CVE data. It has no vulnerability feed, no scheduled +download and no network dependency at startup or during a detection run. What it +has is a boundary: a detector may ask whether a catalogue associates an observed +product and version with published CVE identifiers, and it gets back references +that carry their own provenance. + +Three rules make that defensible rather than misleading: + +1. An association is not a conclusion. A catalogue match never creates a + finding, never raises a severity and never changes a status. It is attached + to a finding that already stood on its own evidence. +2. Only exact matches are reported. Phase 2 matches a normalized product name + and an identical version string. It does not interpret version ranges, + because guessing at a range is how a tool starts inventing vulnerabilities. +3. Absence is not failure. With no catalogue configured, enrichment is simply + unavailable and detection produces exactly the same findings. + +The catalogue is a local JSON file an operator supplies through +`REDDOCK_CVE_CATALOG`. Nothing is downloaded, and a catalogue that cannot be +read or parsed is reported on the detection run as a warning rather than +failing it. +""" + +import json +import logging +from dataclasses import dataclass +from pathlib import Path + +from app.config import get_settings +from app.detection.base import CveReference, Enrichment + +logger = logging.getLogger("reddock.detection") + +CATALOG_SCHEMA = "reddock.cve-catalog/1" +EXACT_VERSION = "exact_version" + +_MAX_CVE_ID = 32 +_MAX_URL = 255 + + +class CatalogError(ValueError): + """Raised when a supplied catalogue cannot be accepted.""" + + +def normalize_product(value: str) -> str: + """One canonical product key, so casing and spacing cannot cause a miss.""" + return " ".join(value.split()).casefold() + + +def normalize_version(value: str) -> str: + return value.strip().casefold() + + +class NoEnrichment(Enrichment): + """The default: RedDock knows of no catalogue, and says so.""" + + id = "none" + version = None + available = False + + def lookup(self, product: str, version: str) -> tuple[CveReference, ...]: + return () + + +@dataclass(frozen=True, slots=True) +class _Entry: + cve_ids: tuple[str, ...] + product: str + version: str + url: str | None + + +class LocalCatalogEnrichment(Enrichment): + """Exact product and version lookups against a local operator catalogue.""" + + id = "local_catalog" + available = True + + def __init__(self, source: str, version: str | None, entries: dict[tuple[str, str], _Entry]): + self.source = source + self.version = version + self._entries = entries + + def __len__(self) -> int: + return len(self._entries) + + def lookup(self, product: str, version: str) -> tuple[CveReference, ...]: + entry = self._entries.get((normalize_product(product), normalize_version(version))) + if entry is None: + return () + return tuple( + CveReference( + cve_id=cve_id, + source=self.source, + source_version=self.version, + match_type=EXACT_VERSION, + matched_product=entry.product, + matched_version=entry.version, + url=entry.url, + ) + for cve_id in entry.cve_ids + ) + + +def parse_catalog(document: object) -> LocalCatalogEnrichment: + """Turn a catalogue document into a lookup, rejecting anything unexpected.""" + settings = get_settings() + if not isinstance(document, dict): + raise CatalogError("A CVE catalogue must be a JSON object") + if document.get("schema") != CATALOG_SCHEMA: + raise CatalogError(f"A CVE catalogue must declare schema {CATALOG_SCHEMA}") + raw_entries = document.get("entries") + if not isinstance(raw_entries, list): + raise CatalogError("A CVE catalogue must contain an entries array") + if len(raw_entries) > settings.max_cve_catalog_entries: + raise CatalogError( + f"A CVE catalogue may hold at most {settings.max_cve_catalog_entries} entries" + ) + + source = _text(document.get("source"), "source") or "operator-supplied" + version = _text(document.get("version"), "version") + entries: dict[tuple[str, str], _Entry] = {} + for index, raw in enumerate(raw_entries): + if not isinstance(raw, dict): + raise CatalogError(f"Catalogue entry {index} is not an object") + product = _text(raw.get("product"), "product") + entry_version = _text(raw.get("version"), "version") + if not product or not entry_version: + raise CatalogError(f"Catalogue entry {index} needs a product and a version") + cve_ids = _cve_ids(raw.get("cve"), index) + url = _text(raw.get("url"), "url", limit=_MAX_URL) + if url and not url.startswith(("http://", "https://")): + raise CatalogError(f"Catalogue entry {index} has a url that is not http or https") + entries[(normalize_product(product), normalize_version(entry_version))] = _Entry( + cve_ids=cve_ids, product=product, version=entry_version, url=url + ) + return LocalCatalogEnrichment(source=source, version=version, entries=entries) + + +def load_enrichment() -> tuple[Enrichment, str | None]: + """Load the configured catalogue, or explain why enrichment is unavailable. + + Returns the enrichment to use and an optional warning. A missing, unreadable + or malformed catalogue is never fatal: detection continues with enrichment + switched off, which is the same behaviour as having configured none. + """ + settings = get_settings() + configured = settings.cve_catalog_path + if not configured: + return NoEnrichment(), None + + path = Path(configured) + try: + if not path.is_file(): + return NoEnrichment(), f"CVE catalogue {configured} does not exist" + size = path.stat().st_size + if size > settings.max_cve_catalog_bytes: + return NoEnrichment(), ( + f"CVE catalogue {configured} is {size} bytes; the limit is " + f"{settings.max_cve_catalog_bytes}" + ) + catalog = parse_catalog(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, CatalogError) as error: + logger.warning("CVE catalogue %s was not loaded: %s", configured, error) + return NoEnrichment(), f"CVE catalogue {configured} was not loaded: {error}" + return catalog, None + + +def _text(value: object, field: str, limit: int = 120) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise CatalogError(f"Catalogue field {field} must be text") + cleaned = value.strip() + if len(cleaned) > limit: + raise CatalogError(f"Catalogue field {field} must be {limit} characters or fewer") + return cleaned or None + + +def _cve_ids(value: object, index: int) -> tuple[str, ...]: + if not isinstance(value, list) or not value: + raise CatalogError(f"Catalogue entry {index} needs a non-empty cve array") + identifiers = [] + for item in value: + if not isinstance(item, str) or not item.strip(): + raise CatalogError(f"Catalogue entry {index} has a CVE identifier that is not text") + identifier = item.strip() + if len(identifier) > _MAX_CVE_ID: + raise CatalogError(f"Catalogue entry {index} has an over-long CVE identifier") + identifiers.append(identifier) + # Sorted and de-duplicated so the same catalogue always enriches identically. + return tuple(sorted(dict.fromkeys(identifiers))) diff --git a/backend/app/detection/fingerprint.py b/backend/app/detection/fingerprint.py new file mode 100644 index 0000000..562f165 --- /dev/null +++ b/backend/app/detection/fingerprint.py @@ -0,0 +1,48 @@ +"""Stable finding identity. + +A fingerprint answers one question: is this the same underlying issue RedDock +already knows about? It is a SHA-256 over a canonical string built only from +concepts that do not move between runs, processes or restarts. Python's built-in +`hash()` is deliberately not used: it is randomized per process, so it would +make a finding look new every time RedDock restarted. + +Two things are left out on purpose: + +- The detector version, so improving a rule does not fork its history. +- The Dockyard, because the finding is a property of the issue. Isolation + between Dockyards comes from the uniqueness constraint on + (dockyard_id, fingerprint), not from the hash. +""" + +from hashlib import sha256 + +FINGERPRINT_SCHEMA = "reddock.finding/1" + +#: A separator that cannot appear in any component, so no combination of values +#: can be made to collide by moving a delimiter into a field. +_SEPARATOR = "\x1f" +_ABSENT = "-" + + +def fingerprint( + *, + detector: str, + rule_id: str, + asset_type: str | None, + asset_identity: str | None, + transport: str | None, + port: int | None, + scope_key: str = "", +) -> str: + """The deterministic identity of one finding.""" + endpoint = f"{transport}/{port}" if transport and port is not None else _ABSENT + parts = ( + FINGERPRINT_SCHEMA, + detector, + rule_id, + asset_type or _ABSENT, + asset_identity or _ABSENT, + endpoint, + scope_key or _ABSENT, + ) + return sha256(_SEPARATOR.join(parts).encode("utf-8")).hexdigest() diff --git a/backend/app/detection/registry.py b/backend/app/detection/registry.py new file mode 100644 index 0000000..4146c7f --- /dev/null +++ b/backend/app/detection/registry.py @@ -0,0 +1,26 @@ +"""The fixed set of detectors RedDock is allowed to run. + +Detectors are listed here explicitly, exactly as discovery adapters are. Nothing +is imported by name from configuration, discovered on a path or loaded from a +plugin directory, so a detector cannot arrive at runtime and the set a reviewer +reads here is the set that runs. +""" + +from app.detection.base import Detector +from app.detection.detectors.http_headers import HttpSecurityHeaderDetector +from app.detection.detectors.service_rules import ServiceRuleDetector +from app.detection.detectors.tls_certificates import TlsCertificateDetector + +_DETECTORS: tuple[Detector, ...] = ( + HttpSecurityHeaderDetector(), + ServiceRuleDetector(), + TlsCertificateDetector(), +) + + +def available_detectors() -> tuple[Detector, ...]: + return _DETECTORS + + +def get_detector(detector_id: str) -> Detector | None: + return next((detector for detector in _DETECTORS if detector.id == detector_id), None) diff --git a/backend/app/detection/runner.py b/backend/app/detection/runner.py new file mode 100644 index 0000000..fcfcb39 --- /dev/null +++ b/backend/app/detection/runner.py @@ -0,0 +1,530 @@ +"""Detection run orchestration. + +The runner owns everything a detector is not trusted with: building the +snapshot, validating what comes back, deciding identity, reconciling against +what is already known, resolving what is no longer reproduced and writing +evidence. A detector only decides what it concluded. + +A detection run is synchronous. It contacts nothing, so there is nothing to wait +on and no reason to leave a run in flight across a restart; when the request +returns, the run is finished and its evidence is on disk. + +Two rules hold every path through here together: + +- A finding must cite at least one observation from the snapshot it was drawn + from. A conclusion nothing supports is refused, not stored with a caveat. +- A detector that returns anything invalid is failed as a whole and its results + are discarded. Half-trusting a detector that has already demonstrated it is + wrong about its own output is not a safe default, and a failed detector + resolves nothing. +""" + +import json +import logging +import re +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import UTC, datetime + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.detection import registry +from app.detection.base import ( + DetectedFinding, + DetectionContext, + DetectionRunStatus, + Detector, + FindingCategory, + FindingConfidence, + Severity, +) +from app.detection.context import build_context +from app.detection.enrichment import load_enrichment +from app.detection.fingerprint import fingerprint as compute_fingerprint +from app.evidence import DETECTION_SCOPE, EVIDENCE_SCHEMA, EvidenceStore +from app.findings import attach_evidence, resolve_absent, upsert_finding +from app.models import DetectionRun, Observation + +logger = logging.getLogger("reddock.detection") + +ACTIVE_STATUSES = (str(DetectionRunStatus.PENDING), str(DetectionRunStatus.RUNNING)) + +_RULE_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") +_MAX_TITLE = 200 +_MAX_DESCRIPTION = 4_000 +_MAX_REMEDIATION = 2_000 +_MAX_SCOPE_KEY = 120 + + +class RunRejected(ValueError): + """Raised when a detection run cannot be started at all.""" + + +class DetectorOutputError(ValueError): + """Raised when a detector returns something RedDock will not store.""" + + +@dataclass(slots=True) +class _DetectorOutcome: + detector: Detector + findings: tuple[DetectedFinding, ...] = () + error: str | None = None + + @property + def ok(self) -> bool: + return self.error is None + + +def create_run(session: Session, dockyard_id: int) -> DetectionRun: + """Persist a pending detection run, refusing to overlap with another.""" + if active_run_count(session, dockyard_id): + raise RunRejected("A detection run is already in flight for this Dockyard") + run = DetectionRun(dockyard_id=dockyard_id, status=str(DetectionRunStatus.PENDING)) + session.add(run) + session.commit() + session.refresh(run) + return run + + +def start_detection(session: Session, dockyard_id: int) -> DetectionRun: + """Create and execute one detection run over a Dockyard's recorded state.""" + run = create_run(session, dockyard_id) + return execute_run(session, run) + + +def active_run_count(session: Session, dockyard_id: int) -> int: + statement = ( + select(func.count()) + .select_from(DetectionRun) + .where( + DetectionRun.dockyard_id == dockyard_id, + DetectionRun.status.in_(ACTIVE_STATUSES), + ) + ) + return session.scalar(statement) or 0 + + +def list_runs(session: Session, dockyard_id: int, limit: int) -> list[DetectionRun]: + statement = ( + select(DetectionRun) + .where(DetectionRun.dockyard_id == dockyard_id) + .order_by(DetectionRun.id.desc()) + .limit(limit) + ) + return list(session.scalars(statement)) + + +def get_run(session: Session, dockyard_id: int, run_id: int) -> DetectionRun | None: + return session.scalar( + select(DetectionRun).where( + DetectionRun.dockyard_id == dockyard_id, DetectionRun.id == run_id + ) + ) + + +def recover_interrupted_runs(session: Session) -> int: + """Mark detection runs that a restart interrupted. + + A detection run completes inside its request, so one still marked active at + startup did not finish. Saying so matters twice over: the record is honest, + and an overlapping run is refused while one looks active, so a run left + behind by a crash would otherwise block this Dockyard's detection for good. + """ + interrupted = list( + session.scalars(select(DetectionRun).where(DetectionRun.status.in_(ACTIVE_STATUSES))) + ) + for run in interrupted: + run.status = str(DetectionRunStatus.FAILED) + run.error = "Interrupted by a RedDock restart" + run.completed_at = datetime.now(UTC) + session.commit() + return len(interrupted) + + +def execute_run(session: Session, run: DetectionRun) -> DetectionRun: + """Run every registered detector over the Dockyard snapshot. Never raises.""" + run.status = str(DetectionRunStatus.RUNNING) + run.started_at = datetime.now(UTC) + session.commit() + + try: + return _perform(session, run) + except Exception: # a detection run must not leave the record ambiguous + logger.exception("Detection run %s failed unexpectedly", run.id) + run.status = str(DetectionRunStatus.FAILED) + run.error = "Detection failed unexpectedly; see the RedDock container log" + run.completed_at = datetime.now(UTC) + session.commit() + return run + + +def _perform(session: Session, run: DetectionRun) -> DetectionRun: + enrichment, warning = load_enrichment() + context = build_context(session, run.dockyard_id, enrichment=enrichment) + observations = _observation_rows(session, context) + + outcomes = [_run_detector(detector, context) for detector in registry.available_detectors()] + stored, new_count, reproduced = _store(session, run, context, observations, outcomes) + resolved = _resolve(session, run, outcomes, reproduced) + + run.status = _status(outcomes) + run.error = _error(outcomes) + run.detectors = [_detector_document(outcome) for outcome in outcomes] + run.enrichment = { + "id": enrichment.id, + "version": enrichment.version, + "available": enrichment.available, + "warning": warning, + } + run.asset_count = len(context.assets) + run.service_count = sum(len(asset.services) for asset in context.assets) + run.observation_count = len(context.observations) + run.finding_count = len(stored) + run.new_finding_count = new_count + run.resolved_finding_count = len(resolved) + session.commit() + + _store_evidence(session, run, context, stored, resolved) + session.commit() + return run + + +def _run_detector(detector: Detector, context: DetectionContext) -> _DetectorOutcome: + """Run one detector in isolation. + + A detector that raises, or returns something invalid, fails on its own. The + other detectors still run, and a failed detector contributes no findings and + resolves none of its earlier ones. + """ + try: + produced = detector.detect(context) + except Exception as error: # a detector must not be able to stop the run + logger.warning("Detector %s failed: %s", detector.id, error) + return _DetectorOutcome(detector=detector, error=_describe(error)) + + try: + return _DetectorOutcome(detector=detector, findings=_validated(detector, produced, context)) + except DetectorOutputError as error: + logger.warning("Detector %s returned output RedDock refused: %s", detector.id, error) + return _DetectorOutcome(detector=detector, error=str(error)) + + +def _validated( + detector: Detector, produced: object, context: DetectionContext +) -> tuple[DetectedFinding, ...]: + """Check everything a detector claimed before any of it is believed.""" + settings = get_settings() + if not isinstance(produced, tuple | list): + raise DetectorOutputError("A detector must return a sequence of findings") + if len(produced) > settings.max_findings_per_detector: + raise DetectorOutputError( + f"A detector may return at most {settings.max_findings_per_detector} findings " + f"in one run; {detector.id} returned {len(produced)}" + ) + + known_observations = {observation.id for observation in context.observations} + for finding in produced: + if not isinstance(finding, DetectedFinding): + raise DetectorOutputError("A detector returned something that is not a finding") + if not _RULE_ID.match(finding.rule_id): + raise DetectorOutputError(f"Unusable rule id: {finding.rule_id!r}") + if not finding.title.strip() or len(finding.title) > _MAX_TITLE: + raise DetectorOutputError(f"{finding.rule_id} has an unusable title") + if not finding.description.strip() or len(finding.description) > _MAX_DESCRIPTION: + raise DetectorOutputError(f"{finding.rule_id} has an unusable description") + if finding.remediation is not None and len(finding.remediation) > _MAX_REMEDIATION: + raise DetectorOutputError(f"{finding.rule_id} has over-long remediation guidance") + if len(finding.scope_key) > _MAX_SCOPE_KEY: + raise DetectorOutputError(f"{finding.rule_id} has an over-long scope key") + if not isinstance(finding.severity, Severity): + raise DetectorOutputError(f"{finding.rule_id} has an unknown severity") + if not isinstance(finding.confidence, FindingConfidence): + raise DetectorOutputError(f"{finding.rule_id} has an unknown confidence") + if not isinstance(finding.category, FindingCategory): + raise DetectorOutputError(f"{finding.rule_id} has an unknown category") + _check_subject(finding, context) + _check_evidence(finding, known_observations, settings.max_evidence_per_finding) + _check_detail(finding) + _check_distinct(detector, produced, context) + return tuple(sorted(produced, key=lambda item: _order_key(item, context))) + + +def _check_distinct( + detector: Detector, produced: Sequence[DetectedFinding], context: DetectionContext +) -> None: + """Two findings in one run may not claim the same identity. + + Deduplication keys on the fingerprint, so a detector that emits 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 the 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. + """ + seen: dict[str, str] = {} + for finding in produced: + identity = _identity(detector.id, finding, context) + if identity in seen: + raise DetectorOutputError( + f"{detector.id} returned {finding.rule_id} and {seen[identity]} with the same " + "identity; a rule that can fire twice for one service needs a scope key" + ) + seen[identity] = finding.rule_id + + +def _identity(detector_id: str, finding: DetectedFinding, context: DetectionContext) -> str: + """The fingerprint of one detected finding, resolved against the snapshot.""" + asset = context.asset(finding.asset_id) + service = context.service(finding.service_id) + return compute_fingerprint( + detector=detector_id, + rule_id=finding.rule_id, + asset_type=asset.asset_type if asset else None, + asset_identity=asset.identity if asset else None, + transport=service.transport if service else None, + port=service.port if service else None, + scope_key=finding.scope_key, + ) + + +def _check_subject(finding: DetectedFinding, context: DetectionContext) -> None: + """A finding may only be about something in the snapshot it was given.""" + asset = context.asset(finding.asset_id) + if finding.asset_id is not None and asset is None: + raise DetectorOutputError(f"{finding.rule_id} names an asset outside this Dockyard") + if finding.service_id is None: + return + service = context.service(finding.service_id) + if service is None: + raise DetectorOutputError(f"{finding.rule_id} names a service outside this Dockyard") + if asset is not None and service.asset_id != asset.id: + raise DetectorOutputError(f"{finding.rule_id} names a service on a different asset") + + +def _check_evidence(finding: DetectedFinding, known: set[int], limit: int) -> None: + if not finding.evidence_observation_ids: + raise DetectorOutputError(f"{finding.rule_id} cites no observation") + if len(finding.evidence_observation_ids) > limit: + raise DetectorOutputError(f"{finding.rule_id} cites more than {limit} observations") + unknown = set(finding.evidence_observation_ids) - known + if unknown: + raise DetectorOutputError( + f"{finding.rule_id} cites observations outside this Dockyard: {sorted(unknown)}" + ) + + +def _check_detail(finding: DetectedFinding) -> None: + if not isinstance(finding.detail, dict): + raise DetectorOutputError(f"{finding.rule_id} has a detail that is not an object") + try: + json.dumps(finding.detail, sort_keys=True) + except (TypeError, ValueError) as error: + raise DetectorOutputError(f"{finding.rule_id} has a detail RedDock cannot store") from error + + +def _order_key(finding: DetectedFinding, context: DetectionContext) -> tuple: + """A stable order, so the same data always produces the same run.""" + asset = context.asset(finding.asset_id) + service = context.service(finding.service_id) + return ( + finding.rule_id, + asset.identity if asset else "", + service.transport if service else "", + service.port if service else 0, + finding.scope_key, + ) + + +def _store( + session: Session, + run: DetectionRun, + context: DetectionContext, + observations: dict[int, Observation], + outcomes: list[_DetectorOutcome], +) -> tuple[list[tuple[DetectedFinding, str, int]], int, dict[str, set[str]]]: + """Reconcile every accepted finding, returning what was stored.""" + stored: list[tuple[DetectedFinding, str, int]] = [] + reproduced: dict[str, set[str]] = {} + new_count = 0 + + for outcome in outcomes: + detector = outcome.detector + reproduced.setdefault(detector.id, set()) + if not outcome.ok: + continue + for detected in outcome.findings: + identity = _identity(detector.id, detected, context) + finding, created = upsert_finding( + session, + dockyard_id=run.dockyard_id, + detection_run_id=run.id, + detector_id=detector.id, + detector_version=detector.version, + fingerprint=identity, + detected=detected, + seen_at=context.generated_at, + ) + attach_evidence( + session, + finding=finding, + detection_run_id=run.id, + observations=observations, + observation_ids=detected.evidence_observation_ids, + ) + reproduced[detector.id].add(identity) + stored.append((detected, identity, finding.id)) + new_count += 1 if created else 0 + session.commit() + return stored, new_count, reproduced + + +def _resolve( + session: Session, + run: DetectionRun, + outcomes: list[_DetectorOutcome], + reproduced: dict[str, set[str]], +) -> list[dict[str, str]]: + """Close out what a successful detector no longer reproduces.""" + resolved: list[dict[str, str]] = [] + for outcome in outcomes: + if not outcome.ok: + continue + for finding in resolve_absent( + session, + dockyard_id=run.dockyard_id, + detector_id=outcome.detector.id, + reproduced=reproduced.get(outcome.detector.id, set()), + resolved_at=datetime.now(UTC), + ): + resolved.append( + { + "fingerprint": finding.fingerprint, + "detector": finding.detector, + "rule_id": finding.rule_id, + "title": finding.title, + } + ) + session.commit() + return resolved + + +def _store_evidence( + session: Session, + run: DetectionRun, + context: DetectionContext, + stored: list[tuple[DetectedFinding, str, int]], + resolved: list[dict[str, str]], +) -> None: + store = EvidenceStore() + result = { + "schema": EVIDENCE_SCHEMA, + "findings": [ + _finding_document(detected, identity, finding_id, context) + for detected, identity, finding_id in stored + ], + "resolved": sorted(resolved, key=lambda item: item["fingerprint"]), + } + normalized = store.write_normalized(run.dockyard_id, run.id, result, DETECTION_SCOPE) + metadata = { + "schema": EVIDENCE_SCHEMA, + "kind": "detection", + "dockyard_id": run.dockyard_id, + "run_id": run.id, + "detectors": run.detectors, + "enrichment": run.enrichment, + "inputs": { + "assets": run.asset_count, + "services": run.service_count, + "observations": run.observation_count, + }, + "counts": { + "findings": run.finding_count, + "new": run.new_finding_count, + "resolved": run.resolved_finding_count, + }, + "status": run.status, + "started_at": _iso(run.started_at), + "recorded_at": _iso(datetime.now(UTC)), + "artifacts": [ + { + "path": normalized.relative_path, + "sha256": normalized.sha256, + "bytes": normalized.size_bytes, + } + ], + } + written = store.write_metadata(run.dockyard_id, run.id, metadata, DETECTION_SCOPE) + + run.evidence_path = store.relative_run_path(run.dockyard_id, run.id, DETECTION_SCOPE) + run.result_sha256 = normalized.sha256 + run.metadata_sha256 = written.sha256 + run.completed_at = datetime.now(UTC) + + +def _finding_document( + detected: DetectedFinding, identity: str, finding_id: int, context: DetectionContext +) -> dict: + asset = context.asset(detected.asset_id) + service = context.service(detected.service_id) + return { + "id": finding_id, + "fingerprint": identity, + "rule_id": detected.rule_id, + "title": detected.title, + "category": str(detected.category), + "severity": str(detected.severity), + "confidence": str(detected.confidence), + "asset": asset.identity if asset else None, + "service": service.endpoint if service else None, + "observations": list(detected.evidence_observation_ids), + "cve_references": [reference.document() for reference in detected.cve_references], + "detail": detected.detail, + } + + +def _detector_document(outcome: _DetectorOutcome) -> dict: + return { + "id": outcome.detector.id, + "version": outcome.detector.version, + "status": "completed" if outcome.ok else "failed", + "findings": len(outcome.findings), + "error": outcome.error, + } + + +def _observation_rows(session: Session, context: DetectionContext) -> dict[int, Observation]: + """The observation rows behind the snapshot, for evidence linking.""" + identifiers = [observation.id for observation in context.observations] + if not identifiers: + return {} + rows = session.scalars( + select(Observation).where( + Observation.dockyard_id == context.dockyard_id, Observation.id.in_(identifiers) + ) + ) + return {row.id: row for row in rows} + + +def _status(outcomes: list[_DetectorOutcome]) -> str: + failed = [outcome for outcome in outcomes if not outcome.ok] + if not failed: + return str(DetectionRunStatus.COMPLETED) + if len(failed) == len(outcomes): + return str(DetectionRunStatus.FAILED) + return str(DetectionRunStatus.PARTIAL) + + +def _error(outcomes: list[_DetectorOutcome]) -> str | None: + failed = [f"{outcome.detector.id}: {outcome.error}" for outcome in outcomes if not outcome.ok] + return "; ".join(failed)[:500] if failed else None + + +def _describe(error: Exception) -> str: + return f"{type(error).__name__}: {error}"[:400] + + +def _iso(moment: datetime | None) -> str | None: + if moment is None: + return None + return (moment if moment.tzinfo else moment.replace(tzinfo=UTC)).isoformat() diff --git a/backend/app/discovery/http_probe.py b/backend/app/discovery/http_probe.py index ee39e4b..6f315a0 100644 --- a/backend/app/discovery/http_probe.py +++ b/backend/app/discovery/http_probe.py @@ -14,6 +14,7 @@ from dataclasses import dataclass from hashlib import sha256 +from app.config import get_settings from app.discovery.base import ( AdapterError, AdapterRequest, @@ -30,10 +31,13 @@ from app.targets import Target, TargetKind HTTP_PROBE = "http_probe" -USER_AGENT = "RedDock/0.2.1 (+https://github.com/chriswayneh/RedDock)" +PROJECT_URL = "https://github.com/chriswayneh/RedDock" # Headers worth retaining. Everything else is dropped so that cookies and other -# session material a target may return are never written to evidence. +# session material a target may return are never written to evidence. The +# security-relevant entries exist because a Phase 2 detector has to be able to +# tell "the endpoint did not send this header" from "RedDock never looked", +# which is why the examined set is recorded alongside the response. _RECORDED_HEADERS = ( "server", "content-type", @@ -41,9 +45,19 @@ "location", "x-powered-by", "strict-transport-security", + "x-content-type-options", + "content-security-policy", + "x-frame-options", ) _CONNECT_TIMEOUT = 10 _MAX_HEADER_LENGTH = 255 +_MAX_VERIFY_MESSAGE = 200 + + +def user_agent() -> str: + """Identify RedDock honestly, with the version the application reports.""" + settings = get_settings() + return f"{settings.app_name}/{settings.version} (+{PROJECT_URL})" @dataclass(frozen=True, slots=True) @@ -143,7 +157,15 @@ def normalize( confidence=Confidence.OBSERVED, asset_identity=target.value, service_port=("tcp", port), - detail={"status": outcome.status, "address": address}, + detail={ + "status": outcome.status, + "address": address, + "scheme": target.scheme, + # What RedDock looked for, so a later reader can separate an + # absent header from one that was never examined. + "headers_examined": list(_RECORDED_HEADERS), + "headers_present": sorted(outcome.headers or {}), + }, ) ] for header, value in (outcome.headers or {}).items(): @@ -207,7 +229,7 @@ def _request( "/", headers={ "Host": authority, - "User-Agent": USER_AGENT, + "User-Agent": user_agent(), "Accept": "*/*", "Connection": "close", }, @@ -231,12 +253,16 @@ def _start_tls( Verification is attempted first. When it fails the handshake is retried without verification so a self-signed lab endpoint can still be observed, - and the recorded evidence states that the certificate was not verified. + and the recorded evidence states that the certificate was not verified and + what the verification actually objected to. The reason matters: an + unverified handshake reveals nothing about the certificate's own fields, + because an unvalidated peer certificate is returned empty. """ try: secure = ssl.create_default_context().wrap_socket(raw_socket, server_hostname=hostname) return secure, _tls_details(secure, verified=True) - except ssl.SSLError: + except ssl.SSLError as error: + failure = _verification_failure(error) raw_socket.close() context = ssl.create_default_context() @@ -248,7 +274,7 @@ def _start_tls( except OSError: retry.close() raise - return secure, _tls_details(secure, verified=False) + return secure, _tls_details(secure, verified=False) | failure def _tls_details(secure: ssl.SSLSocket, *, verified: bool) -> dict[str, object]: @@ -267,6 +293,20 @@ def _tls_details(secure: ssl.SSLSocket, *, verified: bool) -> dict[str, object]: return details +def _verification_failure(error: ssl.SSLError) -> dict[str, object]: + """Record why verification failed, using OpenSSL's own wording. + + The message and code come from OpenSSL's fixed table rather than from the + target, so this is bounded text rather than something the endpoint chose. + """ + if isinstance(error, ssl.SSLCertVerificationError): + return { + "verify_code": error.verify_code, + "verify_message": str(error.verify_message)[:_MAX_VERIFY_MESSAGE], + } + return {"verify_code": None, "verify_message": str(error.reason or error)[:_MAX_VERIFY_MESSAGE]} + + def _distinguished_name(parts: object) -> str: if not isinstance(parts, tuple): return "" diff --git a/backend/app/dockguard.py b/backend/app/dockguard.py index bd727d5..34556ce 100644 --- a/backend/app/dockguard.py +++ b/backend/app/dockguard.py @@ -71,7 +71,7 @@ def normalize_scope_value(raw: str) -> Target: network = target.network() if network is not None and network.num_addresses > get_settings().max_network_addresses: raise ScopeRejected( - f"{target.value} covers {network.num_addresses} addresses; Phase 1 allows at most " + f"{target.value} covers {network.num_addresses} addresses; RedDock allows at most " f"{get_settings().max_network_addresses} per scope entry (IPv4 /24 or IPv6 /120)" ) return target diff --git a/backend/app/evidence.py b/backend/app/evidence.py index 91bcc54..390b6df 100644 --- a/backend/app/evidence.py +++ b/backend/app/evidence.py @@ -1,10 +1,17 @@ -"""RedLedger foundation: retained, hashed discovery evidence. +"""RedLedger: retained, hashed evidence. -Phase 1 keeps only what is needed to re-read a discovery run later: the raw -tool output, the normalized result and a metadata record describing how they -were produced. Every path is derived from integer identifiers and a validated +RedDock keeps what is needed to re-read a run later: the raw tool output, the +normalized result and a metadata record describing how they were produced. Every +path is derived from integer identifiers, a fixed scope name and a validated artifact name, so no operator input can direct a write outside the evidence root. + +Detection uses the same store rather than a second one. Its documents sit under +a `detection` scope so that a detection run and a discovery run that happen to +share an identifier cannot share a directory: + + evidence/// + evidence//detection// """ import json @@ -19,7 +26,13 @@ NORMALIZED_FILE = "normalized/result.json" EVIDENCE_SCHEMA = "reddock.evidence/1" +#: Discovery keeps the original layout so existing evidence stays where it is. +DISCOVERY_SCOPE = "" +DETECTION_SCOPE = "detection" + _ARTIFACT_NAME = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") +#: A closed set, so a scope can never become a path fragment an operator chose. +_SCOPES = frozenset({DISCOVERY_SCOPE, DETECTION_SCOPE}) class EvidenceError(RuntimeError): @@ -44,11 +57,15 @@ def __init__(self, root: Path | None = None, max_bytes: int | None = None) -> No self.root = (root or Path(settings.evidence_dir)).resolve() self.max_bytes = max_bytes or settings.max_evidence_bytes - def run_directory(self, dockyard_id: int, run_id: int) -> Path: - return self.root / str(int(dockyard_id)) / str(int(run_id)) + def run_directory(self, dockyard_id: int, run_id: int, scope: str = DISCOVERY_SCOPE) -> Path: + base = self.root / str(int(dockyard_id)) + if _checked_scope(scope): + base = base / scope + return base / str(int(run_id)) - def relative_run_path(self, dockyard_id: int, run_id: int) -> str: - return f"{int(dockyard_id)}/{int(run_id)}" + def relative_run_path(self, dockyard_id: int, run_id: int, scope: str = DISCOVERY_SCOPE) -> str: + prefix = f"{scope}/" if _checked_scope(scope) else "" + return f"{int(dockyard_id)}/{prefix}{int(run_id)}" def write_raw( self, dockyard_id: int, run_id: int, name: str, media_type: str, content: bytes @@ -61,16 +78,34 @@ def write_raw( dockyard_id, run_id, "raw", f"raw/{name}", media_type, payload, truncated ) - def write_normalized(self, dockyard_id: int, run_id: int, document: dict) -> StoredArtifact: - payload = json.dumps(document, indent=2, sort_keys=True, default=str).encode() + def write_normalized( + self, dockyard_id: int, run_id: int, document: dict, scope: str = DISCOVERY_SCOPE + ) -> StoredArtifact: + payload = _document(document) return self._write( - dockyard_id, run_id, "normalized", NORMALIZED_FILE, "application/json", payload, False + dockyard_id, + run_id, + "normalized", + NORMALIZED_FILE, + "application/json", + payload, + False, + scope, ) - def write_metadata(self, dockyard_id: int, run_id: int, document: dict) -> StoredArtifact: - payload = json.dumps(document, indent=2, sort_keys=True, default=str).encode() + def write_metadata( + self, dockyard_id: int, run_id: int, document: dict, scope: str = DISCOVERY_SCOPE + ) -> StoredArtifact: + payload = _document(document) return self._write( - dockyard_id, run_id, "metadata", METADATA_FILE, "application/json", payload, False + dockyard_id, + run_id, + "metadata", + METADATA_FILE, + "application/json", + payload, + False, + scope, ) def _write( @@ -82,8 +117,9 @@ def _write( media_type: str, payload: bytes, truncated: bool, + scope: str = DISCOVERY_SCOPE, ) -> StoredArtifact: - directory = self.run_directory(dockyard_id, run_id) + directory = self.run_directory(dockyard_id, run_id, scope) destination = (directory / relative_path).resolve() if not destination.is_relative_to(directory.resolve()): raise EvidenceError(f"Evidence path escapes its run directory: {relative_path}") @@ -97,3 +133,14 @@ def _write( sha256=sha256(payload).hexdigest(), truncated=truncated, ) + + +def _checked_scope(scope: str) -> str: + if scope not in _SCOPES: + raise EvidenceError(f"Unknown evidence scope: {scope!r}") + return scope + + +def _document(document: dict) -> bytes: + """Serialize deterministically, so the same result always hashes the same.""" + return json.dumps(document, indent=2, sort_keys=True, default=str).encode() diff --git a/backend/app/findings.py b/backend/app/findings.py new file mode 100644 index 0000000..b2dc1f4 --- /dev/null +++ b/backend/app/findings.py @@ -0,0 +1,254 @@ +"""Persistence rules for findings. + +Findings are reconciled, observations are not. An observation is a dated +statement that is never revisited; a finding is the current conclusion about one +underlying issue, so a repeated detection of the same issue updates the row it +already has. Its identity is its fingerprint, its history is `first_seen`, +`last_seen` and `resolved_at`, and its support is the evidence rows that point +back at the observations it was drawn from. + +Nothing here deletes a finding. An issue that a later run no longer reproduces +is resolved, which is a fact worth keeping; removing the row would erase the +part of the record that shows it was ever true. +""" + +from datetime import datetime + +from sqlalchemy import Select, case, func, select +from sqlalchemy.orm import Session + +from app.detection.base import ( + OPERATOR_OWNED_STATUSES, + DetectedFinding, + FindingStatus, +) +from app.models import EvidenceRecord, Finding, FindingEvidence, Observation + +#: Most severe first, so a findings list opens on what matters. +_SEVERITY_ORDER = { + "critical": 0, + "high": 1, + "medium": 2, + "low": 3, + "informational": 4, +} + + +def upsert_finding( + session: Session, + *, + dockyard_id: int, + detection_run_id: int, + detector_id: str, + detector_version: str, + fingerprint: str, + detected: DetectedFinding, + seen_at: datetime, +) -> tuple[Finding, bool]: + """Store one detected finding, returning it and whether it is new. + + A finding that already exists keeps its identity and its first_seen. What + the detector says about it now replaces what it said before, because the + detector is the authority on its own conclusion. + """ + finding = session.scalar( + select(Finding).where( + Finding.dockyard_id == dockyard_id, Finding.fingerprint == fingerprint + ) + ) + created = finding is None + if finding is None: + finding = Finding( + dockyard_id=dockyard_id, + fingerprint=fingerprint, + first_seen=seen_at, + first_detection_run_id=detection_run_id, + status=str(FindingStatus.OPEN), + ) + session.add(finding) + elif finding.status == str(FindingStatus.RESOLVED): + # It is back. Reopening keeps one history for one issue instead of + # starting a second record that looks unrelated to the first. + finding.status = str(FindingStatus.OPEN) + finding.resolved_at = None + + finding.detector = detector_id + finding.detector_version = detector_version + finding.rule_id = detected.rule_id + finding.title = detected.title[:200] + finding.description = detected.description + finding.category = str(detected.category) + finding.severity = str(detected.severity) + finding.confidence = str(detected.confidence) + finding.asset_id = detected.asset_id + finding.service_id = detected.service_id + finding.remediation = detected.remediation + finding.detail = dict(detected.detail) + finding.cve_references = [reference.document() for reference in detected.cve_references] or None + finding.last_seen = seen_at + finding.last_detection_run_id = detection_run_id + session.flush() + return finding, created + + +def attach_evidence( + session: Session, + *, + finding: Finding, + detection_run_id: int, + observations: dict[int, Observation], + observation_ids: tuple[int, ...], +) -> int: + """Link a finding to the observations that support it. + + Observations are immutable, so re-running detection over unchanged data adds + nothing here; new links appear only when new discovery produced new + observations. + """ + existing = { + row.observation_id + for row in session.scalars( + select(FindingEvidence).where(FindingEvidence.finding_id == finding.id) + ) + } + added = 0 + for observation_id in observation_ids: + observation = observations.get(observation_id) + if observation is None or observation_id in existing: + continue + session.add( + FindingEvidence( + finding_id=finding.id, + observation_id=observation.id, + detection_run_id=detection_run_id, + discovery_run_id=observation.discovery_run_id, + evidence_record_id=_normalized_record_id(session, observation.discovery_run_id), + summary=observation.summary[:500], + ) + ) + existing.add(observation_id) + added += 1 + session.flush() + return added + + +def resolve_absent( + session: Session, + *, + dockyard_id: int, + detector_id: str, + reproduced: set[str], + resolved_at: datetime, +) -> list[Finding]: + """Resolve the open findings this detector no longer reproduces. + + Only findings from the detector that just ran successfully are considered: + a detector that failed must not be able to resolve anything by not running. + Findings an operator suppressed or accepted are left alone, because that + status is their decision and not an observation about the data. + """ + candidates = session.scalars( + select(Finding).where( + Finding.dockyard_id == dockyard_id, + Finding.detector == detector_id, + Finding.status.not_in([str(status) for status in OPERATOR_OWNED_STATUSES]), + ) + ) + resolved = [] + for finding in candidates: + if finding.fingerprint in reproduced or finding.status == str(FindingStatus.RESOLVED): + continue + finding.status = str(FindingStatus.RESOLVED) + finding.resolved_at = resolved_at + resolved.append(finding) + session.flush() + return resolved + + +def findings_query( + dockyard_id: int, + *, + status: str | None = None, + severity: str | None = None, + detector: str | None = None, + asset_id: int | None = None, + service_id: int | None = None, +) -> Select[tuple[Finding]]: + """The one place a findings filter is built, so isolation is not optional.""" + statement = select(Finding).where(Finding.dockyard_id == dockyard_id) + if status is not None: + statement = statement.where(Finding.status == status) + if severity is not None: + statement = statement.where(Finding.severity == severity) + if detector is not None: + statement = statement.where(Finding.detector == detector) + if asset_id is not None: + statement = statement.where(Finding.asset_id == asset_id) + if service_id is not None: + statement = statement.where(Finding.service_id == service_id) + return statement.order_by( + case(_SEVERITY_ORDER, value=Finding.severity, else_=len(_SEVERITY_ORDER)), + Finding.last_seen.desc(), + Finding.id.desc(), + ) + + +def list_findings(session: Session, dockyard_id: int, limit: int, **filters) -> list[Finding]: + return list(session.scalars(findings_query(dockyard_id, **filters).limit(limit))) + + +def get_finding(session: Session, dockyard_id: int, finding_id: int) -> Finding | None: + return session.scalar( + select(Finding).where(Finding.dockyard_id == dockyard_id, Finding.id == finding_id) + ) + + +def open_finding_count(session: Session, dockyard_id: int) -> int: + statement = ( + select(func.count()) + .select_from(Finding) + .where(Finding.dockyard_id == dockyard_id, Finding.status == str(FindingStatus.OPEN)) + ) + return session.scalar(statement) or 0 + + +def set_status( + session: Session, finding: Finding, status: FindingStatus, note: str | None +) -> Finding: + """Apply an operator decision. + + Reopening clears `resolved_at`, because a finding that is open was not + resolved. Suppressing or accepting does not clear it: that history stays. + """ + finding.status = str(status) + finding.status_note = note + if status is FindingStatus.OPEN: + finding.resolved_at = None + session.commit() + session.refresh(finding) + return finding + + +def list_evidence(session: Session, finding_id: int) -> list[FindingEvidence]: + return list( + session.scalars( + select(FindingEvidence) + .where(FindingEvidence.finding_id == finding_id) + .order_by(FindingEvidence.id) + ) + ) + + +def _normalized_record_id(session: Session, discovery_run_id: int | None) -> int | None: + """The hashed normalized result an observation came from, when there is one.""" + if discovery_run_id is None: + return None + return session.scalar( + select(EvidenceRecord.id) + .where( + EvidenceRecord.discovery_run_id == discovery_run_id, + EvidenceRecord.kind == "normalized", + ) + .order_by(EvidenceRecord.id) + .limit(1) + ) diff --git a/backend/app/main.py b/backend/app/main.py index b659146..79be134 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,7 @@ from app.api import router from app.config import get_settings from app.database import SessionLocal, initialize_database +from app.detection.runner import recover_interrupted_runs as recover_interrupted_detections from app.discovery.runner import recover_interrupted_runs STATIC_DIRECTORY = Path(__file__).resolve().parents[2] / "static" @@ -21,10 +22,14 @@ async def lifespan(_: FastAPI): initialize_database() with SessionLocal() as session: # A run that was in flight when the process stopped did not finish. - # Saying so is more useful than leaving it looking active forever. + # Saying so is more useful than leaving it looking active forever, and a + # detection run left active would keep refusing the next one. interrupted = recover_interrupted_runs(session) + detections = recover_interrupted_detections(session) if interrupted: logger.warning("Marked %s discovery run(s) as interrupted by restart", interrupted) + if detections: + logger.warning("Marked %s detection run(s) as interrupted by restart", detections) yield diff --git a/backend/app/models.py b/backend/app/models.py index 191825f..b55bf9f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -146,7 +146,9 @@ class Observation(Base): """A recorded signal. An Observation is not a Finding. It states what an adapter saw, never what - that means for risk; interpretation belongs to a later phase. + that means for risk. Interpretation belongs to a detector, which reads these + rows and produces a separate Finding that cites them; nothing here is ever + rewritten by that. """ __tablename__ = "observations" @@ -194,3 +196,141 @@ class EvidenceRecord(Base): sha256: Mapped[str] = mapped_column(String(64), nullable=False) truncated: Mapped[bool] = mapped_column(nullable=False, default=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class DetectionRun(Base): + """One auditable detection request. + + A detection run is separate from a DiscoveryRun on purpose: discovery + contacts a target, detection only reads what discovery already recorded. + A run therefore has no target, no adapter and no DockGuard decision, and it + takes no operator parameters at all. + """ + + __tablename__ = "detection_runs" + + id: Mapped[int] = mapped_column(primary_key=True) + dockyard_id: Mapped[int] = mapped_column( + ForeignKey("dockyards.id", ondelete="CASCADE"), index=True, nullable=False + ) + status: Mapped[str] = mapped_column(String(16), nullable=False) + detectors: Mapped[list | None] = mapped_column(JSON, nullable=True) + # Which enrichment source was in effect, and why it was not, so a finding + # that carries no CVE reference can be told apart from one RedDock could + # not enrich. + enrichment: Mapped[dict | None] = mapped_column(JSON, nullable=True) + asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + service_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + observation_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + finding_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + new_finding_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + resolved_finding_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + error: Mapped[str | None] = mapped_column(String(500), nullable=True) + evidence_path: Mapped[str | None] = mapped_column(String(255), nullable=True) + # RedLedger hashes for this run's two retained documents. They are columns + # rather than evidence_records rows because that table's discovery_run_id is + # NOT NULL and Phase 2 stays additive; see ARCHITECTURE.md. + metadata_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) + result_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class Finding(Base): + """A normalized security-relevant conclusion drawn by one named detector. + + A Finding is not an Observation. An observation states what an adapter saw; + a finding states what a specific detector concluded from one or more + observations, and it cannot exist without them. Identity is the fingerprint: + within a Dockyard the same underlying issue is one row whose last_seen and + evidence grow, never a new row per detection run. + """ + + __tablename__ = "findings" + __table_args__ = ( + UniqueConstraint("dockyard_id", "fingerprint", name="uq_finding_fingerprint"), + Index("ix_finding_dockyard_status", "dockyard_id", "status"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + dockyard_id: Mapped[int] = mapped_column( + ForeignKey("dockyards.id", ondelete="CASCADE"), index=True, nullable=False + ) + fingerprint: Mapped[str] = mapped_column(String(64), nullable=False) + detector: Mapped[str] = mapped_column(String(48), nullable=False) + detector_version: Mapped[str] = mapped_column(String(16), nullable=False) + rule_id: Mapped[str] = mapped_column(String(64), nullable=False) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + category: Mapped[str] = mapped_column(String(32), nullable=False) + # Severity and confidence are deliberately separate: how much this would + # matter, and how sure RedDock is that it is true, are different questions. + severity: Mapped[str] = mapped_column(String(16), nullable=False) + confidence: Mapped[str] = mapped_column(String(16), nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="open") + status_note: Mapped[str | None] = mapped_column(String(255), nullable=True) + asset_id: Mapped[int | None] = mapped_column( + ForeignKey("assets.id", ondelete="CASCADE"), index=True, nullable=True + ) + service_id: Mapped[int | None] = mapped_column( + ForeignKey("services.id", ondelete="CASCADE"), nullable=True + ) + remediation: Mapped[str | None] = mapped_column(Text, nullable=True) + detail: Mapped[dict | None] = mapped_column(JSON, nullable=True) + # Enrichment, never proof: a CVE association describes a catalogue entry + # that matched an observed product and version, not a confirmed weakness. + cve_references: Mapped[list | None] = mapped_column(JSON, nullable=True) + first_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + last_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + first_detection_run_id: Mapped[int | None] = mapped_column( + ForeignKey("detection_runs.id", ondelete="SET NULL"), nullable=True + ) + last_detection_run_id: Mapped[int | None] = mapped_column( + ForeignKey("detection_runs.id", ondelete="SET NULL"), index=True, nullable=True + ) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + evidence: Mapped[list["FindingEvidence"]] = relationship( + back_populates="finding", cascade="all, delete-orphan" + ) + + +class FindingEvidence(Base): + """The link that makes a finding checkable. + + One row per observation that supported a finding, carrying the hashed + RedLedger artifact that observation came from. A finding with no rows here + is refused by the detection runner, because a conclusion without evidence is + exactly what RedDock exists not to produce. + """ + + __tablename__ = "finding_evidence" + __table_args__ = ( + UniqueConstraint("finding_id", "observation_id", name="uq_finding_evidence"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + finding_id: Mapped[int] = mapped_column( + ForeignKey("findings.id", ondelete="CASCADE"), index=True, nullable=False + ) + observation_id: Mapped[int] = mapped_column( + ForeignKey("observations.id", ondelete="CASCADE"), nullable=False + ) + detection_run_id: Mapped[int | None] = mapped_column( + ForeignKey("detection_runs.id", ondelete="SET NULL"), nullable=True + ) + discovery_run_id: Mapped[int | None] = mapped_column( + ForeignKey("discovery_runs.id", ondelete="SET NULL"), nullable=True + ) + evidence_record_id: Mapped[int | None] = mapped_column( + ForeignKey("evidence_records.id", ondelete="SET NULL"), nullable=True + ) + summary: Mapped[str] = mapped_column(String(500), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + finding: Mapped[Finding] = relationship(back_populates="evidence") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index aca842c..47474da 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,8 +1,9 @@ from datetime import UTC, datetime from typing import Annotated -from pydantic import AfterValidator, BaseModel, ConfigDict, Field +from pydantic import AfterValidator, BaseModel, BeforeValidator, ConfigDict, Field +from app.detection.base import OPERATOR_STATUSES, FindingStatus from app.dockguard import ScopeRuleType @@ -173,6 +174,133 @@ class EvidenceRecordRead(BaseModel): created_at: UtcDatetime +def _operator_status(value: FindingStatus) -> FindingStatus: + """`resolved` is RedDock's answer, not an operator's.""" + if value not in OPERATOR_STATUSES: + allowed = ", ".join(str(status) for status in OPERATOR_STATUSES) + raise ValueError(f"A finding status may be set to one of: {allowed}") + return value + + +OperatorStatus = Annotated[FindingStatus, AfterValidator(_operator_status)] + + +def _as_list(value: object) -> object: + """A finding with no enrichment stores null; the wire says empty.""" + return value if value is not None else [] + + +class DetectionCreate(BaseModel): + """A detection request carries nothing. + + Every registered detector runs over everything the Dockyard already + recorded. There is no target, no detector selection and no option, so there + is no operator-supplied value for a detector to act on. + """ + + model_config = ConfigDict(extra="forbid") + + +class DetectionRunRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + dockyard_id: int + status: str + detectors: list[dict] | None + enrichment: dict | None + asset_count: int + service_count: int + observation_count: int + finding_count: int + new_finding_count: int + resolved_finding_count: int + error: str | None + evidence_path: str | None + metadata_sha256: str | None + result_sha256: str | None + created_at: UtcDatetime + started_at: UtcDatetime | None + completed_at: UtcDatetime | None + + +class CveReferenceRead(BaseModel): + cve_id: str + source: str + source_version: str | None = None + match_type: str + matched_product: str + matched_version: str + url: str | None = None + + +class FindingRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + fingerprint: str + detector: str + detector_version: str + rule_id: str + title: str + category: str + severity: str + confidence: str + status: str + status_note: str | None + asset_id: int | None + service_id: int | None + first_seen: UtcDatetime + last_seen: UtcDatetime + resolved_at: UtcDatetime | None + first_detection_run_id: int | None + last_detection_run_id: int | None + cve_references: Annotated[list[CveReferenceRead], BeforeValidator(_as_list)] = Field( + default_factory=list + ) + asset_label: str | None = None + service_endpoint: str | None = None + evidence_count: int = 0 + + +class FindingEvidenceRead(BaseModel): + """One observation that supported a finding, with the hash that proves it.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + observation_id: int + discovery_run_id: int | None + detection_run_id: int | None + evidence_record_id: int | None + summary: str + created_at: UtcDatetime + evidence_path: str | None = None + sha256: str | None = None + + +class FindingDetailRead(FindingRead): + description: str + remediation: str | None + detail: dict | None + evidence: list[FindingEvidenceRead] = Field(default_factory=list) + + +class FindingStatusUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + status: OperatorStatus + note: str | None = Field(default=None, max_length=255) + + +class DetectorRead(BaseModel): + id: str + version: str + title: str + description: str + consumes: list[str] + + class ProfileRead(BaseModel): name: str title: str diff --git a/backend/app/targets.py b/backend/app/targets.py index fa65207..eeba810 100644 --- a/backend/app/targets.py +++ b/backend/app/targets.py @@ -125,7 +125,7 @@ def _normalize_url(text: str) -> Target: host = _normalize_hostname(hostname) rendered = host - # Paths, queries and fragments are dropped: Phase 1 probes an origin and + # Paths, queries and fragments are dropped: RedDock probes an origin and # never a location, so retaining them would imply a capability RedDock does # not have and would widen what a stored target string can carry. port_suffix = "" if port in (None, _DEFAULT_PORTS[scheme]) else f":{port}" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 1f08ebf..56d3db5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "reddock-backend" -version = "0.2.1" +version = "0.3.0" description = "RedDock Core API" requires-python = ">=3.13" dependencies = [ diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..b227d6c --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1,7 @@ +"""Test package. + +This file makes `tests` a real package so that shared helpers import the same +way under any invocation. Without it `from tests.phase1 import Recorder` +resolves only when the working directory happens to be on `sys.path`, which is +true for `python -m pytest` and false for the bare `pytest` that CI runs. +""" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 21b883d..4d5ecc9 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -61,3 +61,13 @@ def _add(dockyard: int, target: str, rule: str = "include") -> dict: return response.json() return _add + + +@pytest.fixture() +def recorder(dockyard_id: int): + """Record Phase 1 state for a Dockyard so detection has something to read.""" + import app.database + from tests.phase1 import Recorder + + with app.database.SessionLocal() as db_session: + yield Recorder(db_session, dockyard_id) diff --git a/backend/tests/phase1.py b/backend/tests/phase1.py new file mode 100644 index 0000000..9ff7b08 --- /dev/null +++ b/backend/tests/phase1.py @@ -0,0 +1,224 @@ +"""Helpers that record Phase 1 state for Phase 2 tests to reason over. + +Detection reads what discovery already stored, so a detection test needs a +Dockyard that looks like one discovery has run against it. These helpers write +that state directly instead of running an adapter, which keeps detection tests +about detection and keeps them off the network. +""" + +from datetime import UTC, datetime, timedelta + +from sqlalchemy.orm import Session + +from app.models import Asset, DiscoveryRun, EvidenceRecord, Observation, Service + +BASE_TIME = datetime(2026, 8, 1, 12, 0, tzinfo=UTC) + +#: The header set the Phase 1 HTTP probe records, as an observation states it. +EXAMINED_HEADERS = [ + "server", + "content-type", + "content-length", + "location", + "x-powered-by", + "strict-transport-security", + "x-content-type-options", + "content-security-policy", + "x-frame-options", +] + + +class Recorder: + """Writes the discovery-shaped rows a detection run reads.""" + + def __init__(self, session: Session, dockyard_id: int) -> None: + self.session = session + self.dockyard_id = dockyard_id + self._clock = 0 + + def _next_time(self) -> datetime: + self._clock += 1 + return BASE_TIME + timedelta(minutes=self._clock) + + def discovery_run(self, adapter: str = "http", profile: str = "http_probe") -> DiscoveryRun: + run = DiscoveryRun( + dockyard_id=self.dockyard_id, + adapter=adapter, + adapter_version="1.0.0", + profile=profile, + requested_target="http://127.0.0.1:8080", + normalized_target="http://127.0.0.1:8080", + status="completed", + decision="allowed", + decision_reason="Target is covered by authorized scope entry 127.0.0.1", + ) + self.session.add(run) + self.session.flush() + self.session.add( + EvidenceRecord( + dockyard_id=self.dockyard_id, + discovery_run_id=run.id, + kind="normalized", + relative_path="normalized/result.json", + media_type="application/json", + size_bytes=128, + sha256="a" * 64, + truncated=False, + ) + ) + self.session.flush() + return run + + def asset(self, identity: str, asset_type: str = "web", **fields) -> Asset: + seen = self._next_time() + asset = Asset( + dockyard_id=self.dockyard_id, + asset_type=asset_type, + identity=identity, + display_name=fields.pop("display_name", identity), + ip_address=fields.pop("ip_address", "127.0.0.1"), + hostname=fields.pop("hostname", None), + first_seen=seen, + last_seen=seen, + ) + self.session.add(asset) + self.session.flush() + return asset + + def service(self, asset: Asset, port: int, transport: str = "tcp", **fields) -> Service: + seen = self._next_time() + service = Service( + asset_id=asset.id, + transport=transport, + port=port, + state=fields.pop("state", "open"), + service_name=fields.pop("service_name", None), + product=fields.pop("product", None), + version=fields.pop("version", None), + first_seen=seen, + last_seen=seen, + ) + self.session.add(service) + self.session.flush() + return service + + def observation( + self, + run: DiscoveryRun, + observation_type: str, + summary: str, + *, + asset: Asset | None = None, + service: Service | None = None, + detail: dict | None = None, + confidence: str = "observed", + adapter: str = "http", + observed_at: datetime | None = None, + ) -> Observation: + observation = Observation( + dockyard_id=self.dockyard_id, + discovery_run_id=run.id, + asset_id=asset.id if asset else None, + service_id=service.id if service else None, + adapter=adapter, + observation_type=observation_type, + summary=summary, + detail=detail, + confidence=confidence, + raw_reference=f"{self.dockyard_id}/{run.id}", + observed_at=observed_at or self._next_time(), + ) + self.session.add(observation) + self.session.flush() + return observation + + def http_endpoint( + self, + origin: str, + *, + status: int = 200, + headers: dict[str, str] | None = None, + examined: list[str] | None = None, + port: int | None = None, + run: DiscoveryRun | None = None, + ) -> tuple[Asset, Service, DiscoveryRun]: + """One probed HTTP origin: asset, service, response and header records.""" + scheme = origin.split("://", 1)[0] + run = run or self.discovery_run() + asset = self.asset(origin) + service = self.service( + asset, port if port is not None else (443 if scheme == "https" else 80), + service_name=scheme, + ) + sent = headers or {} + self.observation( + run, + "http_response", + f"{origin} returned HTTP {status}", + asset=asset, + service=service, + detail={ + "status": status, + "address": "127.0.0.1", + "scheme": scheme, + "headers_examined": EXAMINED_HEADERS if examined is None else examined, + "headers_present": sorted(sent), + }, + ) + for name, value in sent.items(): + self.observation( + run, + "http_header", + f"{origin} reported {name}: {value}", + asset=asset, + service=service, + detail={"header": name, "value": value}, + confidence="reported", + ) + self.session.commit() + return asset, service, run + + def tls_endpoint( + self, origin: str, *, tls: dict, run: DiscoveryRun | None = None + ) -> tuple[Asset, Service, DiscoveryRun]: + run = run or self.discovery_run() + asset = self.asset(origin) + service = self.service(asset, 443, service_name="https") + self.observation( + run, + "tls_session", + f"{origin} presented a TLS session", + asset=asset, + service=service, + detail=tls, + ) + self.session.commit() + return asset, service, run + + def identified_service( + self, + address: str, + port: int, + *, + service_name: str, + product: str | None = None, + version: str | None = None, + run: DiscoveryRun | None = None, + ) -> tuple[Asset, Service, DiscoveryRun]: + run = run or self.discovery_run(adapter="nmap", profile="service_discovery") + asset = self.asset(address, asset_type="host", ip_address=address) + service = self.service( + asset, port, service_name=service_name, product=product, version=version + ) + self.observation( + run, + "service_identified", + f"TCP/{port} identified as {product or service_name} {version or ''}".strip(), + asset=asset, + service=service, + detail={"name": service_name, "product": product, "version": version}, + confidence="reported", + adapter="nmap", + ) + self.session.commit() + return asset, service, run diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 4ddc3c1..3159413 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1,3 +1,14 @@ +import json +import tomllib +from pathlib import Path + +import pytest + +from app.config import get_settings + +BACKEND = Path(__file__).resolve().parents[1] + + def test_health_endpoint(client): response = client.get("/api/health") assert response.status_code == 200 @@ -8,7 +19,7 @@ def test_version_endpoint(client): response = client.get("/api/version") assert response.status_code == 200 assert response.json()["name"] == "RedDock" - assert response.json()["version"] == "0.2.1" + assert response.json()["version"] == get_settings().version def test_create_list_and_retrieve_dockyard(client): @@ -49,7 +60,27 @@ def test_dockyard_is_persisted_for_a_new_client(client): def test_version_reports_the_current_phase(client): - assert client.get("/api/version").json()["phase"].startswith("Phase 1") + assert client.get("/api/version").json()["phase"].startswith("Phase 2") + + +def test_the_application_and_both_packages_report_one_version(client): + """A release aligns the application, the API and the packages, or it is not one.""" + reported = client.get("/api/version").json()["version"] + backend = tomllib.loads(BACKEND.joinpath("pyproject.toml").read_bytes().decode()) + assert reported == backend["project"]["version"] + + package = BACKEND.parent / "frontend" / "package.json" + if not package.exists(): # the backend test image mounts backend/ alone + pytest.skip("The frontend package is not present in this checkout") + assert reported == json.loads(package.read_text(encoding="utf-8"))["version"] + + +def test_detectors_are_advertised_without_an_offensive_capability(client): + detectors = client.get("/api/detectors").json() + assert detectors + for detector in detectors: + assert detector["id"] and detector["version"] and detector["consumes"] + assert not {"exploit", "attack", "bruteforce"} & set(detector["id"].split(".")) def test_adapters_are_advertised_with_their_safe_profiles(client): diff --git a/backend/tests/test_detection_contract.py b/backend/tests/test_detection_contract.py new file mode 100644 index 0000000..c20afd2 --- /dev/null +++ b/backend/tests/test_detection_contract.py @@ -0,0 +1,163 @@ +"""Structural guarantees about what a detector is able to do. + +These are not behaviour tests. They read the detection package itself and assert +that a detector has no way to reach a network, a process, the filesystem or the +database, so the claim in the architecture is checked rather than asserted. A +comment saying "detectors do not execute anything" is worth what a test makes it +worth. +""" + +import ast +import dataclasses +from pathlib import Path + +import pytest + +from app.detection import registry +from app.detection.base import ( + DetectedFinding, + DetectionContext, + Detector, + ObservationView, +) + +DETECTION = Path(__file__).resolve().parents[1] / "app" / "detection" + +#: Anything that could reach outside the process, plus the database itself. +#: A detector reasons about a snapshot; it does not go and get one. +FORBIDDEN_MODULES = frozenset( + { + "asyncio", + "ctypes", + "http", + "importlib", + "multiprocessing", + "os", + "pathlib", + "requests", + "shutil", + "socket", + "sqlalchemy", + "ssl", + "subprocess", + "urllib", + "app.database", + "app.models", + "app.discovery", + "app.dockguard", + "app.evidence", + } +) + +FORBIDDEN_CALLS = frozenset({"eval", "exec", "compile", "__import__", "open", "globals"}) + + +def modules_under(directory: Path) -> list[Path]: + return sorted(path for path in directory.rglob("*.py") if path.stat().st_size) + + +def imported_modules(source: str) -> set[str]: + names: set[str] = set() + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Import): + names.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module and not node.level: + names.add(node.module) + return names + + +def roots(names: set[str]) -> set[str]: + """Both `socket` and `app.models` shapes, so neither form slips through.""" + expanded = set() + for name in names: + parts = name.split(".") + expanded.update({parts[0], ".".join(parts[:2])}) + return expanded + + +@pytest.mark.parametrize( + "module", modules_under(DETECTION / "detectors"), ids=lambda path: path.name +) +def test_a_detector_cannot_reach_a_network_a_process_or_the_database(module: Path): + forbidden = roots(imported_modules(module.read_text(encoding="utf-8"))) & FORBIDDEN_MODULES + assert not forbidden, f"{module.name} imports {sorted(forbidden)}" + + +@pytest.mark.parametrize("module", modules_under(DETECTION), ids=lambda path: path.name) +def test_no_part_of_detection_evaluates_or_executes_text(module: Path): + tree = ast.parse(module.read_text(encoding="utf-8")) + called = { + node.func.id + for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } + assert not called & FORBIDDEN_CALLS, f"{module.name} calls {sorted(called & FORBIDDEN_CALLS)}" + + +def test_the_detector_contract_itself_reaches_nothing(): + forbidden = roots(imported_modules((DETECTION / "base.py").read_text(encoding="utf-8"))) + assert not forbidden & FORBIDDEN_MODULES + + +def test_enrichment_reads_a_local_file_and_never_the_network(): + """CVE enrichment is a local catalogue. There is no client to switch on.""" + imported = roots(imported_modules((DETECTION / "enrichment.py").read_text(encoding="utf-8"))) + assert not imported & {"socket", "ssl", "http", "urllib", "requests", "subprocess"} + assert "pathlib" in imported + + +def test_detectors_are_registered_explicitly_and_not_discovered(): + source = (DETECTION / "registry.py").read_text(encoding="utf-8") + assert not roots(imported_modules(source)) & {"importlib", "pkgutil", "os", "pathlib"} + assert isinstance(registry.available_detectors(), tuple) + + +def test_every_registered_detector_declares_its_contract(): + detectors = registry.available_detectors() + assert detectors + for detector in detectors: + assert isinstance(detector, Detector) + assert detector.id and detector.version and detector.title and detector.description + assert detector.consumes + + +def test_detector_identifiers_are_unique(): + identifiers = [detector.id for detector in registry.available_detectors()] + assert len(identifiers) == len(set(identifiers)) + + +def test_an_unknown_detector_is_not_conjured_into_existence(): + assert registry.get_detector("metasploit") is None + assert registry.get_detector("http.security_headers") is not None + + +def test_the_snapshot_a_detector_receives_is_immutable(): + context = DetectionContext(dockyard_id=1, generated_at=None) + assert dataclasses.is_dataclass(context) + with pytest.raises(dataclasses.FrozenInstanceError): + context.dockyard_id = 2 + + +def test_an_observation_detail_cannot_be_edited_by_a_detector(): + observation = ObservationView( + id=1, + discovery_run_id=1, + asset_id=1, + service_id=1, + adapter="http", + observation_type="http_response", + summary="", + confidence="observed", + observed_at=None, + detail={"status": 200}, + ) + with pytest.raises(dataclasses.FrozenInstanceError): + observation.summary = "rewritten" + + +def test_a_detected_finding_is_a_value_not_a_row(): + """A detector produces values. Only the runner decides what is stored.""" + assert dataclasses.is_dataclass(DetectedFinding) + fields = {field.name for field in dataclasses.fields(DetectedFinding)} + assert "evidence_observation_ids" in fields + assert not fields & {"id", "fingerprint", "status", "first_seen", "last_seen"} diff --git a/backend/tests/test_detection_runner.py b/backend/tests/test_detection_runner.py new file mode 100644 index 0000000..fc1c352 --- /dev/null +++ b/backend/tests/test_detection_runner.py @@ -0,0 +1,730 @@ +"""Detection run orchestration tests. + +The rules these protect are the ones that separate a findings list from a +guess: a finding must come from a detector and be supported by observations, the +same issue must stay one record, and nothing is ever removed because it stopped +being reproduced. +""" + +import json +import os +import subprocess +import sys +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.detection import registry as detection_registry +from app.detection import runner as detection_runner +from app.detection.base import ( + DetectedFinding, + DetectionContext, + Detector, + FindingCategory, + FindingConfidence, + FindingStatus, + Severity, +) +from app.detection.fingerprint import fingerprint +from app.models import Finding, FindingEvidence, Observation +from tests.phase1 import Recorder + + +class StubDetector(Detector): + """A detector whose output the test decides, so orchestration is what is tested.""" + + id = "stub.detector" + version = "1.0.0" + title = "Stub detector" + description = "Deterministic detector used by the test suite." + consumes = ("http_response",) + + def __init__(self, produce=None, failure: Exception | None = None) -> None: + self.produce = produce if produce is not None else _one_finding + self.failure = failure + self.contexts: list[DetectionContext] = [] + + def detect(self, context: DetectionContext) -> tuple[DetectedFinding, ...]: + self.contexts.append(context) + if self.failure is not None: + raise self.failure + return self.produce(context) + + +class SecondDetector(StubDetector): + id = "stub.second" + + +def _one_finding(context: DetectionContext) -> tuple[DetectedFinding, ...]: + observation = context.observations[0] + return ( + DetectedFinding( + rule_id="stub-rule", + title="Stub finding", + description="A deterministic finding produced by the test suite.", + category=FindingCategory.HARDENING, + severity=Severity.LOW, + confidence=FindingConfidence.HIGH, + evidence_observation_ids=(observation.id,), + asset_id=observation.asset_id, + service_id=observation.service_id, + remediation="Nothing; this exists so orchestration can be tested.", + detail={"stub": True}, + ), + ) + + +def _no_findings(context: DetectionContext) -> tuple[DetectedFinding, ...]: + return () + + +def install(monkeypatch: pytest.MonkeyPatch, *detectors: Detector) -> None: + monkeypatch.setattr(detection_registry, "available_detectors", lambda: tuple(detectors)) + monkeypatch.setattr(detection_runner.registry, "available_detectors", lambda: tuple(detectors)) + + +def detect(recorder: Recorder): + return detection_runner.start_detection(recorder.session, recorder.dockyard_id) + + +def findings_of(recorder: Recorder) -> list[Finding]: + return list( + recorder.session.scalars( + select(Finding) + .where(Finding.dockyard_id == recorder.dockyard_id) + .order_by(Finding.id) + ) + ) + + +@pytest.fixture() +def endpoint(client: TestClient, recorder: Recorder): + recorder.http_endpoint("https://127.0.0.1:8443", headers={}) + return recorder + + +class TestTheObservationFindingBoundary: + def test_discovery_alone_never_produces_a_finding(self, endpoint: Recorder): + """Observations exist the moment discovery runs. Findings do not.""" + assert endpoint.session.scalars(select(Observation)).all() + assert findings_of(endpoint) == [] + + def test_a_finding_is_only_created_by_a_detection_run( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + install(monkeypatch, StubDetector()) + run = detect(endpoint) + + assert run.status == "completed" + assert len(findings_of(endpoint)) == 1 + assert findings_of(endpoint)[0].detector == "stub.detector" + + def test_detection_never_alters_an_observation( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + before = [ + (row.id, row.summary, row.observation_type, row.confidence) + for row in endpoint.session.scalars(select(Observation).order_by(Observation.id)) + ] + install(monkeypatch, StubDetector()) + detect(endpoint) + + after = [ + (row.id, row.summary, row.observation_type, row.confidence) + for row in endpoint.session.scalars(select(Observation).order_by(Observation.id)) + ] + assert after == before + + def test_a_finding_that_cites_no_observation_is_refused( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + def unsupported(context): + return ( + DetectedFinding( + rule_id="unsupported", + title="Nothing supports this", + description="A conclusion with no evidence behind it.", + category=FindingCategory.HARDENING, + severity=Severity.HIGH, + confidence=FindingConfidence.HIGH, + evidence_observation_ids=(), + ), + ) + + install(monkeypatch, StubDetector(produce=unsupported)) + run = detect(endpoint) + + assert run.status == "failed" + assert "cites no observation" in run.error + assert findings_of(endpoint) == [] + + +class TestDetectorOutputValidation: + def test_a_detector_that_finds_nothing_completes_cleanly( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + install(monkeypatch, StubDetector(produce=_no_findings)) + run = detect(endpoint) + + assert (run.status, run.finding_count, run.error) == ("completed", 0, None) + assert findings_of(endpoint) == [] + + @pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("severity", "catastrophic", "unknown severity"), + ("confidence", "certain", "unknown confidence"), + ("category", "vibes", "unknown category"), + ("rule_id", "Not A Rule Id", "Unusable rule id"), + ("title", "", "unusable title"), + ("description", " ", "unusable description"), + ], + ) + def test_invalid_output_fails_the_detector_and_stores_nothing( + self, + endpoint: Recorder, + monkeypatch: pytest.MonkeyPatch, + field: str, + value: str, + message: str, + ): + def malformed(context): + base = _one_finding(context)[0] + return ( + base, + DetectedFinding( + **{ + **{ + "rule_id": "second-rule", + "title": "Second", + "description": "Another finding in the same batch.", + "category": FindingCategory.HARDENING, + "severity": Severity.LOW, + "confidence": FindingConfidence.HIGH, + "evidence_observation_ids": base.evidence_observation_ids, + }, + field: value, + } + ), + ) + + install(monkeypatch, StubDetector(produce=malformed)) + run = detect(endpoint) + + assert run.status == "failed" + assert message in run.error + # The valid finding in the same batch is discarded too: a detector that + # is wrong about its own output is not half-trusted. + assert findings_of(endpoint) == [] + + def test_a_finding_about_another_dockyard_is_refused( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch, client: TestClient + ): + other = client.post("/api/dockyards", json={"name": "Other"}).json()["id"] + outsider = Recorder(endpoint.session, other) + outsider.http_endpoint("https://10.0.0.5:8443", headers={}) + stolen = outsider.session.scalars( + select(Observation).where(Observation.dockyard_id == other) + ).first() + + def cross_dockyard(context): + return ( + DetectedFinding( + rule_id="cross-dockyard", + title="Evidence from elsewhere", + description="Cites an observation from another workspace.", + category=FindingCategory.HARDENING, + severity=Severity.LOW, + confidence=FindingConfidence.HIGH, + evidence_observation_ids=(stolen.id,), + ), + ) + + install(monkeypatch, StubDetector(produce=cross_dockyard)) + run = detect(endpoint) + + assert run.status == "failed" + assert "outside this Dockyard" in run.error + + def test_a_detector_returning_the_wrong_shape_entirely_is_refused( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + install(monkeypatch, StubDetector(produce=lambda context: "not findings")) + run = detect(endpoint) + + assert run.status == "failed" + assert findings_of(endpoint) == [] + + def test_more_findings_than_the_limit_fails_rather_than_truncating( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + def flood(context): + base = _one_finding(context)[0] + limit = 501 + return tuple( + DetectedFinding( + rule_id=f"flood-{index}", + title=f"Flood {index}", + description="One of very many.", + category=FindingCategory.HARDENING, + severity=Severity.LOW, + confidence=FindingConfidence.HIGH, + evidence_observation_ids=base.evidence_observation_ids, + ) + for index in range(limit) + ) + + install(monkeypatch, StubDetector(produce=flood)) + run = detect(endpoint) + + assert run.status == "failed" + assert "at most" in run.error + assert findings_of(endpoint) == [] + + +class TestFingerprintAndDeduplication: + def test_a_fingerprint_is_a_sha256_over_stable_concepts(self): + arguments = { + "detector": "http.security_headers", + "rule_id": "hsts-not-set", + "asset_type": "web", + "asset_identity": "https://127.0.0.1:8443", + "transport": "tcp", + "port": 8443, + } + value = fingerprint(**arguments) + + assert len(value) == 64 and int(value, 16) >= 0 + assert fingerprint(**arguments) == value + assert fingerprint(**{**arguments, "port": 9443}) != value + assert fingerprint(**{**arguments, "rule_id": "other"}) != value + + def test_a_fingerprint_is_identical_across_processes(self): + """Python's randomized hash() would make a finding look new after a restart.""" + script = ( + "from app.detection.fingerprint import fingerprint;" + "print(fingerprint(detector='d', rule_id='r', asset_type='web'," + " asset_identity='https://a', transport='tcp', port=443))" + ) + values = set() + for seed in ("0", "1", "random"): + environment = {**os.environ, "PYTHONHASHSEED": seed} + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + check=True, + env=environment, + cwd=str(Path(__file__).resolve().parents[1]), + ) + values.add(result.stdout.strip()) + assert len(values) == 1 + + def test_a_repeated_detection_updates_rather_than_duplicating( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + install(monkeypatch, StubDetector()) + first = detect(endpoint) + original = findings_of(endpoint)[0] + first_seen, first_id = original.first_seen, original.id + + second = detect(endpoint) + rows = findings_of(endpoint) + + assert len(rows) == 1 + assert rows[0].id == first_id + assert rows[0].first_seen == first_seen + assert rows[0].last_seen >= first_seen + assert rows[0].first_detection_run_id == first.id + assert rows[0].last_detection_run_id == second.id + assert (second.finding_count, second.new_finding_count) == (1, 0) + + def test_the_second_run_reports_no_new_findings( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + install(monkeypatch, StubDetector()) + assert detect(endpoint).new_finding_count == 1 + assert detect(endpoint).new_finding_count == 0 + + def test_the_same_issue_in_two_dockyards_stays_two_findings( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch, client: TestClient + ): + install(monkeypatch, StubDetector()) + detect(endpoint) + + other = client.post("/api/dockyards", json={"name": "Other"}).json()["id"] + outsider = Recorder(endpoint.session, other) + outsider.http_endpoint("https://127.0.0.1:8443", headers={}) + detection_runner.start_detection(outsider.session, other) + + rows = list(endpoint.session.scalars(select(Finding).order_by(Finding.id))) + assert len(rows) == 2 + # Identical issue, identical fingerprint, isolated by Dockyard. + assert rows[0].fingerprint == rows[1].fingerprint + assert {row.dockyard_id for row in rows} == {endpoint.dockyard_id, other} + + +class TestEvidence: + def test_a_finding_is_linked_to_the_observation_it_came_from( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + install(monkeypatch, StubDetector()) + detect(endpoint) + finding = findings_of(endpoint)[0] + + evidence = list( + endpoint.session.scalars( + select(FindingEvidence).where(FindingEvidence.finding_id == finding.id) + ) + ) + assert len(evidence) == 1 + observation = endpoint.session.get(Observation, evidence[0].observation_id) + assert observation is not None + assert evidence[0].discovery_run_id == observation.discovery_run_id + # And through to the hashed RedLedger artifact that observation came from. + assert evidence[0].evidence_record_id is not None + + def test_repeated_detection_over_unchanged_data_adds_no_evidence_rows( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + install(monkeypatch, StubDetector()) + detect(endpoint) + detect(endpoint) + + assert len(list(endpoint.session.scalars(select(FindingEvidence)))) == 1 + + def test_a_detection_run_writes_hashed_evidence( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch, environment: Path + ): + from hashlib import sha256 + + install(monkeypatch, StubDetector()) + run = detect(endpoint) + + directory = environment / "evidence" / str(endpoint.dockyard_id) / "detection" / str(run.id) + result = directory / "normalized" / "result.json" + metadata = directory / "metadata.json" + + assert run.evidence_path == f"{endpoint.dockyard_id}/detection/{run.id}" + assert sha256(result.read_bytes()).hexdigest() == run.result_sha256 + assert sha256(metadata.read_bytes()).hexdigest() == run.metadata_sha256 + + document = json.loads(metadata.read_text()) + assert document["kind"] == "detection" + assert document["detectors"][0]["id"] == "stub.detector" + assert document["counts"] == {"findings": 1, "new": 1, "resolved": 0} + + def test_detection_evidence_never_collides_with_discovery_evidence( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch, environment: Path + ): + install(monkeypatch, StubDetector()) + run = detect(endpoint) + root = environment / "evidence" / str(endpoint.dockyard_id) + + assert (root / "detection" / str(run.id)).is_dir() + # Discovery run 1 keeps the original layout. + assert not (root / str(run.id) / "metadata.json").exists() + + def test_the_normalized_result_is_byte_identical_for_identical_input( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch, environment: Path + ): + install(monkeypatch, StubDetector()) + first = detect(endpoint) + second = detect(endpoint) + root = environment / "evidence" / str(endpoint.dockyard_id) / "detection" + + assert (root / str(first.id) / "normalized" / "result.json").read_bytes() == ( + root / str(second.id) / "normalized" / "result.json" + ).read_bytes() + + +class TestLifecycle: + def test_a_finding_no_longer_reproduced_is_resolved_and_kept( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + detector = StubDetector() + install(monkeypatch, detector) + detect(endpoint) + finding_id = findings_of(endpoint)[0].id + + detector.produce = _no_findings + run = detect(endpoint) + rows = findings_of(endpoint) + + assert len(rows) == 1 and rows[0].id == finding_id + assert rows[0].status == "resolved" + assert rows[0].resolved_at is not None + assert run.resolved_finding_count == 1 + + def test_history_survives_resolution( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + detector = StubDetector() + install(monkeypatch, detector) + detect(endpoint) + first_seen = findings_of(endpoint)[0].first_seen + + detector.produce = _no_findings + detect(endpoint) + resolved = findings_of(endpoint)[0] + + assert resolved.first_seen == first_seen + assert resolved.last_seen is not None + assert list( + endpoint.session.scalars( + select(FindingEvidence).where(FindingEvidence.finding_id == resolved.id) + ) + ) + + def test_a_resolved_finding_that_returns_is_reopened_not_duplicated( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + detector = StubDetector() + install(monkeypatch, detector) + detect(endpoint) + detector.produce = _no_findings + detect(endpoint) + detector.produce = _one_finding + detect(endpoint) + + rows = findings_of(endpoint) + assert len(rows) == 1 + assert rows[0].status == "open" + assert rows[0].resolved_at is None + + @pytest.mark.parametrize("decision", ["suppressed", "accepted"]) + def test_an_operator_decision_survives_a_run_that_no_longer_reproduces_it( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch, decision: str + ): + detector = StubDetector() + install(monkeypatch, detector) + detect(endpoint) + finding = findings_of(endpoint)[0] + finding.status = decision + endpoint.session.commit() + + detector.produce = _no_findings + run = detect(endpoint) + + assert findings_of(endpoint)[0].status == decision + assert run.resolved_finding_count == 0 + + def test_an_operator_decision_survives_the_issue_reappearing( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + install(monkeypatch, StubDetector()) + detect(endpoint) + finding = findings_of(endpoint)[0] + finding.status = str(FindingStatus.SUPPRESSED) + endpoint.session.commit() + + detect(endpoint) + refreshed = findings_of(endpoint)[0] + assert refreshed.status == "suppressed" + # It was still seen; only the operator changes what counts as open. + assert refreshed.last_seen is not None + + +class TestDetectorFailureIsolation: + def test_one_failing_detector_does_not_stop_the_others( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + install(monkeypatch, StubDetector(failure=RuntimeError("boom")), SecondDetector()) + run = detect(endpoint) + + assert run.status == "partial" + assert "boom" in run.error + statuses = {entry["id"]: entry["status"] for entry in run.detectors} + assert statuses == {"stub.detector": "failed", "stub.second": "completed"} + assert [finding.detector for finding in findings_of(endpoint)] == ["stub.second"] + + def test_a_failed_detector_resolves_nothing( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + """Not running is not evidence that an issue went away.""" + detector = StubDetector() + install(monkeypatch, detector) + detect(endpoint) + + detector.failure = RuntimeError("boom") + run = detect(endpoint) + + assert run.status == "failed" + assert run.resolved_finding_count == 0 + assert findings_of(endpoint)[0].status == "open" + + def test_a_run_where_every_detector_fails_is_failed( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + install( + monkeypatch, + StubDetector(failure=RuntimeError("one")), + SecondDetector(failure=RuntimeError("two")), + ) + run = detect(endpoint) + + assert run.status == "failed" + assert findings_of(endpoint) == [] + + +class TestIsolationAndReach: + def test_a_detector_only_sees_its_own_dockyard( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch, client: TestClient + ): + other = client.post("/api/dockyards", json={"name": "Other"}).json()["id"] + Recorder(endpoint.session, other).http_endpoint("https://10.0.0.5:8443", headers={}) + + detector = StubDetector() + install(monkeypatch, detector) + detect(endpoint) + + context = detector.contexts[0] + assert context.dockyard_id == endpoint.dockyard_id + assert [asset.identity for asset in context.assets] == ["https://127.0.0.1:8443"] + + def test_a_detection_run_starts_no_process_and_opens_no_socket( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + import socket + + def refuse(*args, **kwargs): + raise AssertionError("Detection must not reach outside the database") + + monkeypatch.setattr(subprocess, "run", refuse) + monkeypatch.setattr(subprocess, "Popen", refuse) + monkeypatch.setattr(socket, "create_connection", refuse) + monkeypatch.setattr(socket, "socket", refuse) + monkeypatch.setattr(os, "system", refuse) + + install(monkeypatch, StubDetector()) + run = detect(endpoint) + assert run.status == "completed" + + def test_a_detection_run_asks_dockguard_for_nothing( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + """Detection needs no scope decision because it reaches no target.""" + import app.dockguard + + def refuse(*args, **kwargs): + raise AssertionError("Detection must not evaluate scope; it contacts nothing") + + monkeypatch.setattr(app.dockguard, "evaluate", refuse) + monkeypatch.setattr(app.dockguard, "system_resolver", refuse) + + install(monkeypatch, StubDetector()) + assert detect(endpoint).status == "completed" + + +class TestRunRecord: + def test_a_run_records_what_it_read( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + install(monkeypatch, StubDetector()) + run = detect(endpoint) + + assert run.asset_count == 1 + assert run.service_count == 1 + assert run.observation_count >= 1 + assert run.started_at is not None and run.completed_at is not None + + def test_a_run_records_that_enrichment_was_unavailable( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + install(monkeypatch, StubDetector()) + run = detect(endpoint) + + assert run.enrichment == { + "id": "none", + "version": None, + "available": False, + "warning": None, + } + + def test_an_overlapping_run_is_refused( + self, endpoint: Recorder, monkeypatch: pytest.MonkeyPatch + ): + from app.models import DetectionRun + + endpoint.session.add( + DetectionRun(dockyard_id=endpoint.dockyard_id, status="running") + ) + endpoint.session.commit() + + install(monkeypatch, StubDetector()) + with pytest.raises(detection_runner.RunRejected): + detect(endpoint) + + +def test_detection_uses_the_snapshot_time_for_seen_timestamps( + endpoint: Recorder, monkeypatch: pytest.MonkeyPatch +): + """One run has one clock, so every finding it stores agrees about when.""" + install(monkeypatch, StubDetector()) + before = datetime.now(UTC) + detect(endpoint) + finding = findings_of(endpoint)[0] + + # SQLite hands timestamps back without a zone; the stored value is UTC. + stored = finding.first_seen.replace(tzinfo=UTC) + assert finding.first_seen == finding.last_seen + assert stored >= before.replace(microsecond=0) + + +def test_a_detection_run_interrupted_by_a_restart_is_marked_and_unblocks_the_next( + endpoint: Recorder, monkeypatch: pytest.MonkeyPatch +): + """An overlapping run is refused, so a stale active run must not be permanent.""" + from app.models import DetectionRun + + stale = DetectionRun(dockyard_id=endpoint.dockyard_id, status="running") + endpoint.session.add(stale) + endpoint.session.commit() + + assert detection_runner.recover_interrupted_runs(endpoint.session) == 1 + endpoint.session.refresh(stale) + assert stale.status == "failed" + assert "restart" in stale.error + + install(monkeypatch, StubDetector()) + assert detect(endpoint).status == "completed" + + +def test_two_findings_claiming_one_identity_fail_the_detector( + endpoint: Recorder, monkeypatch: pytest.MonkeyPatch +): + """Deduplication keys on the fingerprint, so a collision is a detector bug.""" + + def collide(context): + base = _one_finding(context)[0] + return (base, base) + + install(monkeypatch, StubDetector(produce=collide)) + run = detect(endpoint) + + assert run.status == "failed" + assert "same identity" in run.error + assert findings_of(endpoint) == [] + + +def test_a_scope_key_lets_one_rule_fire_twice_for_one_service( + endpoint: Recorder, monkeypatch: pytest.MonkeyPatch +): + """The discriminator exists for rules that legitimately repeat.""" + import dataclasses + + def twice(context): + base = _one_finding(context)[0] + return ( + dataclasses.replace(base, scope_key="first"), + dataclasses.replace(base, scope_key="second"), + ) + + install(monkeypatch, StubDetector(produce=twice)) + run = detect(endpoint) + + assert run.status == "completed" + assert len(findings_of(endpoint)) == 2 diff --git a/backend/tests/test_evidence.py b/backend/tests/test_evidence.py index b4df5c6..727ed7b 100644 --- a/backend/tests/test_evidence.py +++ b/backend/tests/test_evidence.py @@ -3,7 +3,7 @@ import pytest -from app.evidence import EvidenceError, EvidenceStore +from app.evidence import DETECTION_SCOPE, EvidenceError, EvidenceStore def test_artifacts_are_written_under_the_run_directory_and_hashed(environment: Path): @@ -50,3 +50,28 @@ def test_run_directories_are_isolated_by_dockyard_and_run(environment: Path): store = EvidenceStore() assert store.relative_run_path(4, 9) == "4/9" assert store.run_directory(4, 9) == (environment / "evidence" / "4" / "9").resolve() + + +def test_detection_evidence_is_written_under_its_own_scope(environment: Path): + """A detection run and a discovery run may share an id but never a directory.""" + store = EvidenceStore() + store.write_metadata(1, 4, {"kind": "discovery"}) + detection = store.write_metadata(1, 4, {"kind": "detection"}, DETECTION_SCOPE) + + assert store.relative_run_path(1, 4, DETECTION_SCOPE) == "1/detection/4" + assert detection.sha256 != store.write_metadata(1, 4, {"kind": "discovery"}).sha256 + assert (environment / "evidence" / "1" / "4" / "metadata.json").exists() + assert (environment / "evidence" / "1" / "detection" / "4" / "metadata.json").exists() + + +def test_an_unknown_evidence_scope_is_refused(environment: Path): + with pytest.raises(EvidenceError): + EvidenceStore().write_metadata(1, 1, {}, "../../escape") + + +def test_a_document_hashes_the_same_every_time(environment: Path): + store = EvidenceStore() + document = {"b": 2, "a": [3, 1]} + assert store.write_normalized(9, 1, document).sha256 == store.write_normalized( + 9, 2, dict(reversed(list(document.items()))) + ).sha256 diff --git a/backend/tests/test_findings_api.py b/backend/tests/test_findings_api.py new file mode 100644 index 0000000..3bf1afd --- /dev/null +++ b/backend/tests/test_findings_api.py @@ -0,0 +1,270 @@ +"""Phase 2 API tests, exercised through the real detectors.""" + +import pytest +from fastapi.testclient import TestClient + +from tests.phase1 import Recorder + + +@pytest.fixture() +def detected(client: TestClient, recorder: Recorder, dockyard_id: int) -> dict: + """One Dockyard with an unprotected HTTPS origin and one detection run.""" + recorder.http_endpoint("https://127.0.0.1:8443", headers={}) + response = client.post(f"/api/dockyards/{dockyard_id}/detections", json={}) + assert response.status_code == 201, response.text + return response.json() + + +def findings(client: TestClient, dockyard_id: int, **params) -> list[dict]: + response = client.get(f"/api/dockyards/{dockyard_id}/findings", params=params) + assert response.status_code == 200, response.text + return response.json() + + +class TestDetectors: + def test_the_registered_detectors_are_published_with_what_they_read( + self, client: TestClient + ): + published = client.get("/api/detectors").json() + assert {detector["id"] for detector in published} == { + "http.security_headers", + "service.rules", + "tls.certificates", + } + assert all(detector["consumes"] for detector in published) + + +class TestDetectionRuns: + def test_a_detection_run_completes_within_the_request(self, detected: dict): + assert detected["status"] == "completed" + assert detected["completed_at"] is not None + assert detected["finding_count"] >= 1 + assert detected["new_finding_count"] == detected["finding_count"] + + def test_a_detection_request_accepts_no_operator_parameters( + self, client: TestClient, dockyard_id: int + ): + """There is no target, no detector selection and no option to pass.""" + for body in ( + {"target": "10.0.0.5"}, + {"detector": "http.security_headers"}, + {"severity": "critical"}, + {"command": "nmap -A"}, + ): + response = client.post(f"/api/dockyards/{dockyard_id}/detections", json=body) + assert response.status_code == 422, body + + def test_runs_are_listed_and_readable( + self, client: TestClient, dockyard_id: int, detected: dict + ): + listed = client.get(f"/api/dockyards/{dockyard_id}/detections").json() + assert [run["id"] for run in listed] == [detected["id"]] + + single = client.get(f"/api/dockyards/{dockyard_id}/detections/{detected['id']}") + assert single.status_code == 200 + assert single.json()["result_sha256"] == detected["result_sha256"] + + def test_a_run_states_which_detectors_ran(self, detected: dict): + statuses = {entry["id"]: entry["status"] for entry in detected["detectors"]} + assert statuses == { + "http.security_headers": "completed", + "service.rules": "completed", + "tls.certificates": "completed", + } + + def test_a_run_states_that_cve_enrichment_was_unavailable(self, detected: dict): + assert detected["enrichment"]["available"] is False + assert detected["enrichment"]["id"] == "none" + + def test_an_unknown_run_is_not_found(self, client: TestClient, dockyard_id: int): + assert client.get(f"/api/dockyards/{dockyard_id}/detections/9999").status_code == 404 + + def test_detection_on_an_unknown_dockyard_is_not_found(self, client: TestClient): + assert client.post("/api/dockyards/9999/detections", json={}).status_code == 404 + + +class TestFindings: + def test_findings_carry_severity_and_confidence_separately( + self, client: TestClient, dockyard_id: int, detected: dict + ): + rows = findings(client, dockyard_id) + assert rows + for finding in rows: + assert finding["severity"] in {"informational", "low", "medium", "high", "critical"} + assert finding["confidence"] in {"low", "medium", "high"} + assert finding["status"] == "open" + assert finding["detector"] and finding["rule_id"] + assert finding["evidence_count"] >= 1 + + def test_findings_name_the_asset_and_service_they_are_about( + self, client: TestClient, dockyard_id: int, detected: dict + ): + finding = findings(client, dockyard_id)[0] + assert finding["asset_label"] == "https://127.0.0.1:8443" + assert finding["service_endpoint"] == "TCP/443" + + def test_a_finding_detail_carries_its_evidence_and_the_hash_behind_it( + self, client: TestClient, dockyard_id: int, detected: dict + ): + listed = findings(client, dockyard_id)[0] + detail = client.get(f"/api/dockyards/{dockyard_id}/findings/{listed['id']}").json() + + assert detail["description"] and detail["remediation"] + assert detail["evidence"] + evidence = detail["evidence"][0] + assert evidence["observation_id"] and evidence["summary"] + assert evidence["discovery_run_id"] == 1 + assert evidence["detection_run_id"] == detected["id"] + assert len(evidence["sha256"]) == 64 + assert evidence["evidence_path"] == "normalized/result.json" + + def test_an_unknown_finding_is_not_found(self, client: TestClient, dockyard_id: int): + assert client.get(f"/api/dockyards/{dockyard_id}/findings/9999").status_code == 404 + + +class TestFilters: + def test_findings_can_be_filtered_by_severity_status_and_detector( + self, client: TestClient, dockyard_id: int, detected: dict + ): + assert findings(client, dockyard_id, severity="low") + assert findings(client, dockyard_id, severity="critical") == [] + assert findings(client, dockyard_id, status="open") + assert findings(client, dockyard_id, status="resolved") == [] + assert findings(client, dockyard_id, detector="http.security_headers") + assert findings(client, dockyard_id, detector="service.rules") == [] + + def test_findings_can_be_filtered_by_asset_and_service( + self, client: TestClient, dockyard_id: int, detected: dict + ): + finding = findings(client, dockyard_id)[0] + assert findings(client, dockyard_id, asset_id=finding["asset_id"]) + assert findings(client, dockyard_id, asset_id=finding["asset_id"] + 100) == [] + assert findings(client, dockyard_id, service_id=finding["service_id"]) + + @pytest.mark.parametrize( + "params", + [ + {"severity": "catastrophic"}, + {"status": "ignored"}, + {"limit": 0}, + {"limit": 501}, + {"asset_id": 0}, + {"asset_id": "all"}, + {"service_id": -1}, + ], + ) + def test_an_invalid_filter_is_refused( + self, client: TestClient, dockyard_id: int, detected: dict, params: dict + ): + response = client.get(f"/api/dockyards/{dockyard_id}/findings", params=params) + assert response.status_code == 422, params + + def test_findings_are_returned_most_severe_first( + self, client: TestClient, dockyard_id: int, recorder: Recorder + ): + recorder.http_endpoint("http://127.0.0.1:8080", headers={}) + recorder.identified_service("192.168.1.10", 23, service_name="telnet") + client.post(f"/api/dockyards/{dockyard_id}/detections", json={}) + + severities = [finding["severity"] for finding in findings(client, dockyard_id)] + order = ["critical", "high", "medium", "low", "informational"] + assert severities == sorted(severities, key=order.index) + + +class TestOperatorDecisions: + def test_a_finding_can_be_suppressed_and_keeps_its_evidence( + self, client: TestClient, dockyard_id: int, detected: dict + ): + finding = findings(client, dockyard_id)[0] + response = client.patch( + f"/api/dockyards/{dockyard_id}/findings/{finding['id']}", + json={"status": "suppressed", "note": "Accepted for the lab network"}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "suppressed" + assert body["status_note"] == "Accepted for the lab network" + assert body["evidence"] + + def test_a_finding_can_be_accepted_and_reopened( + self, client: TestClient, dockyard_id: int, detected: dict + ): + finding = findings(client, dockyard_id)[0] + path = f"/api/dockyards/{dockyard_id}/findings/{finding['id']}" + + assert client.patch(path, json={"status": "accepted"}).json()["status"] == "accepted" + assert client.patch(path, json={"status": "open"}).json()["status"] == "open" + + def test_an_operator_cannot_declare_a_finding_resolved( + self, client: TestClient, dockyard_id: int, detected: dict + ): + """Resolution is a fact about the data, so RedDock sets it.""" + finding = findings(client, dockyard_id)[0] + response = client.patch( + f"/api/dockyards/{dockyard_id}/findings/{finding['id']}", + json={"status": "resolved"}, + ) + assert response.status_code == 422 + + def test_an_unknown_status_is_refused( + self, client: TestClient, dockyard_id: int, detected: dict + ): + finding = findings(client, dockyard_id)[0] + path = f"/api/dockyards/{dockyard_id}/findings/{finding['id']}" + assert client.patch(path, json={"status": "fixed"}).status_code == 422 + assert client.patch(path, json={"status": "open", "extra": 1}).status_code == 422 + + def test_updating_an_unknown_finding_is_not_found( + self, client: TestClient, dockyard_id: int + ): + assert ( + client.patch( + f"/api/dockyards/{dockyard_id}/findings/9999", json={"status": "open"} + ).status_code + == 404 + ) + + +class TestDockyardIsolation: + def test_a_finding_is_not_reachable_from_another_dockyard( + self, client: TestClient, dockyard_id: int, detected: dict + ): + finding = findings(client, dockyard_id)[0] + other = client.post("/api/dockyards", json={"name": "Other"}).json()["id"] + + assert client.get(f"/api/dockyards/{other}/findings").json() == [] + assert client.get(f"/api/dockyards/{other}/findings/{finding['id']}").status_code == 404 + assert ( + client.patch( + f"/api/dockyards/{other}/findings/{finding['id']}", json={"status": "suppressed"} + ).status_code + == 404 + ) + + def test_a_detection_run_is_not_reachable_from_another_dockyard( + self, client: TestClient, detected: dict + ): + other = client.post("/api/dockyards", json={"name": "Other"}).json()["id"] + assert client.get(f"/api/dockyards/{other}/detections").json() == [] + assert ( + client.get(f"/api/dockyards/{other}/detections/{detected['id']}").status_code == 404 + ) + + +class TestPhase1RemainsIntact: + def test_an_observation_still_carries_no_severity_or_verdict( + self, client: TestClient, dockyard_id: int, detected: dict + ): + observations = client.get(f"/api/dockyards/{dockyard_id}/observations").json() + assert observations + for observation in observations: + assert "severity" not in observation + assert "status" not in observation + assert observation["confidence"] in {"observed", "reported"} + + def test_detection_adds_nothing_to_the_discovery_evidence_ledger( + self, client: TestClient, dockyard_id: int, detected: dict + ): + records = client.get(f"/api/dockyards/{dockyard_id}/evidence").json() + assert {record["kind"] for record in records} == {"normalized"} diff --git a/backend/tests/test_http_adapter.py b/backend/tests/test_http_adapter.py index d476218..7c4af67 100644 --- a/backend/tests/test_http_adapter.py +++ b/backend/tests/test_http_adapter.py @@ -10,7 +10,7 @@ import pytest from app.discovery.base import AdapterRequest, AssetType, Confidence -from app.discovery.http_probe import HTTP_PROBE, HttpProbeAdapter +from app.discovery.http_probe import HTTP_PROBE, PROJECT_URL, HttpProbeAdapter from app.targets import normalize_target @@ -31,6 +31,20 @@ def log_message(self, *_args) -> None: return +class SecureHandler(Handler): + """A handler that sends the response-level protections a detector looks for.""" + + def do_HEAD(self) -> None: # method name is fixed by BaseHTTPRequestHandler + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.send_header("Content-Length", "0") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Content-Security-Policy", "default-src 'self'") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Set-Cookie", "session=must-not-be-recorded") + self.end_headers() + + @pytest.fixture() def origin() -> Iterator[str]: server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) @@ -43,6 +57,18 @@ def origin() -> Iterator[str]: server.server_close() +@pytest.fixture() +def secure_origin() -> Iterator[str]: + server = ThreadingHTTPServer(("127.0.0.1", 0), SecureHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + server.server_close() + + def request(target: str) -> AdapterRequest: return AdapterRequest(target=normalize_target(target), profile=HTTP_PROBE) @@ -101,3 +127,49 @@ def test_the_adapter_only_accepts_url_targets(): assert adapter.supports(normalize_target("http://127.0.0.1:8080")) assert not adapter.supports(normalize_target("127.0.0.1")) assert not adapter.supports(normalize_target("192.168.1.0/24")) + + +def test_the_response_states_which_headers_were_examined(origin: str): + """A later reader must be able to tell an absent header from an unexamined one.""" + result = HttpProbeAdapter().run(request(origin)) + response = next( + observation + for observation in result.observations + if observation.observation_type == "http_response" + ) + + examined = response.detail["headers_examined"] + assert { + "strict-transport-security", + "x-content-type-options", + "content-security-policy", + "x-frame-options", + } <= set(examined) + assert response.detail["scheme"] == "http" + assert response.detail["headers_present"] == sorted( + observation.detail["header"] + for observation in result.observations + if observation.observation_type == "http_header" + ) + + +def test_security_headers_are_retained_when_the_endpoint_sends_them(secure_origin: str): + result = HttpProbeAdapter().run(request(secure_origin)) + headers = { + observation.detail["header"]: observation.detail["value"] + for observation in result.observations + if observation.observation_type == "http_header" + } + + assert headers["x-content-type-options"] == "nosniff" + assert headers["content-security-policy"] == "default-src 'self'" + assert headers["x-frame-options"] == "DENY" + assert "set-cookie" not in headers + + +def test_the_user_agent_reports_the_version_the_application_reports(): + """A hard-coded version would drift the moment RedDock is released again.""" + from app.config import get_settings + from app.discovery.http_probe import user_agent + + assert user_agent() == f"RedDock/{get_settings().version} (+{PROJECT_URL})" diff --git a/backend/tests/test_http_header_detector.py b/backend/tests/test_http_header_detector.py new file mode 100644 index 0000000..65d53da --- /dev/null +++ b/backend/tests/test_http_header_detector.py @@ -0,0 +1,229 @@ +"""HTTP security-header detector tests. + +These assert as hard on what the detector refuses to say as on what it says. A +header detector that reports every absent header on every response is easy to +write and useless to read. +""" + +from fastapi.testclient import TestClient + +from app.detection.detectors.http_headers import HttpSecurityHeaderDetector +from app.detection.registry import available_detectors +from tests.phase1 import Recorder + +SECURE_HEADERS = { + "strict-transport-security": "max-age=63072000", + "x-content-type-options": "nosniff", + "content-security-policy": "default-src 'self'; frame-ancestors 'none'", + "x-frame-options": "DENY", +} + + +def rules(recorder: Recorder) -> set[str]: + context = _context(recorder) + return {finding.rule_id for finding in HttpSecurityHeaderDetector().detect(context)} + + +def _context(recorder: Recorder): + from app.detection.context import build_context + + recorder.session.commit() + return build_context(recorder.session, recorder.dockyard_id) + + +def test_a_fully_protected_https_response_produces_no_finding( + client: TestClient, recorder: Recorder +): + recorder.http_endpoint("https://127.0.0.1:8443", headers=SECURE_HEADERS) + assert rules(recorder) == set() + + +def test_one_missing_header_produces_exactly_one_finding( + client: TestClient, recorder: Recorder +): + headers = dict(SECURE_HEADERS) + del headers["x-content-type-options"] + recorder.http_endpoint("https://127.0.0.1:8443", headers=headers) + + assert rules(recorder) == {"content-type-options-not-nosniff"} + + +def test_several_missing_headers_produce_one_finding_each( + client: TestClient, recorder: Recorder +): + recorder.http_endpoint("https://127.0.0.1:8443", headers={}) + + assert rules(recorder) == { + "hsts-not-set", + "content-type-options-not-nosniff", + "content-security-policy-not-set", + "frame-protection-not-set", + } + + +def test_plaintext_http_is_the_finding_and_hsts_is_not(client: TestClient, recorder: Recorder): + """HSTS over plaintext is meaningless, so its absence is not reported there.""" + recorder.http_endpoint("http://127.0.0.1:8080", headers={}) + found = rules(recorder) + + assert "plaintext-http" in found + assert "hsts-not-set" not in found + + +def test_https_is_not_reported_as_plaintext(client: TestClient, recorder: Recorder): + recorder.http_endpoint("https://127.0.0.1:8443", headers=SECURE_HEADERS) + assert "plaintext-http" not in rules(recorder) + + +def test_a_plaintext_redirect_to_https_is_not_a_finding(client: TestClient, recorder: Recorder): + """Redirecting HTTP to HTTPS is the correct configuration, not a defect.""" + recorder.http_endpoint( + "http://127.0.0.1:8080", + status=301, + headers={"location": "https://127.0.0.1:8443/"}, + ) + assert rules(recorder) == set() + + +def test_a_redirect_is_not_evidence_that_content_headers_are_missing( + client: TestClient, recorder: Recorder +): + recorder.http_endpoint("https://127.0.0.1:8443", status=302, headers=SECURE_HEADERS | {}) + assert rules(recorder) == set() + + recorder.http_endpoint( + "https://127.0.0.1:9443", status=302, headers={"location": "https://elsewhere.local/"} + ) + # Only the header that still applies to a redirect is reported. + assert rules(recorder) == {"hsts-not-set"} + + +def test_a_server_error_is_not_judged_for_content_headers(client: TestClient, recorder: Recorder): + recorder.http_endpoint( + "https://127.0.0.1:8443", + status=503, + headers={"strict-transport-security": "max-age=63072000"}, + ) + assert rules(recorder) == set() + + +def test_a_client_error_is_still_a_real_response(client: TestClient, recorder: Recorder): + recorder.http_endpoint("https://127.0.0.1:8443", status=404, headers=SECURE_HEADERS) + assert rules(recorder) == set() + + recorder.http_endpoint("https://127.0.0.1:9443", status=404, headers={}) + assert "content-security-policy-not-set" in rules(recorder) + + +def test_a_header_the_probe_never_examined_is_never_reported( + client: TestClient, recorder: Recorder +): + """Absence of evidence is not evidence of absence, and RedDock says so.""" + recorder.http_endpoint( + "https://127.0.0.1:8443", + headers={}, + examined=["server", "content-type"], + ) + assert rules(recorder) == set() + + +def test_a_response_recorded_without_an_examined_set_produces_nothing( + client: TestClient, recorder: Recorder +): + recorder.http_endpoint("https://127.0.0.1:8443", headers={}, examined=[]) + assert rules(recorder) == set() + + +def test_content_security_policy_frame_ancestors_replaces_x_frame_options( + client: TestClient, recorder: Recorder +): + headers = dict(SECURE_HEADERS) + del headers["x-frame-options"] + recorder.http_endpoint("https://127.0.0.1:8443", headers=headers) + + assert "frame-protection-not-set" not in rules(recorder) + + +def test_a_policy_without_frame_ancestors_does_not_replace_x_frame_options( + client: TestClient, recorder: Recorder +): + headers = dict(SECURE_HEADERS) + del headers["x-frame-options"] + headers["content-security-policy"] = "default-src 'self'" + recorder.http_endpoint("https://127.0.0.1:8443", headers=headers) + + assert "frame-protection-not-set" in rules(recorder) + + +def test_a_wrong_content_type_options_value_is_reported_with_what_was_seen( + client: TestClient, recorder: Recorder +): + headers = dict(SECURE_HEADERS) | {"x-content-type-options": "sniff"} + recorder.http_endpoint("https://127.0.0.1:8443", headers=headers) + + findings = HttpSecurityHeaderDetector().detect(_context(recorder)) + finding = next(item for item in findings if item.rule_id == "content-type-options-not-nosniff") + assert "sniff" in finding.description + assert finding.detail["x-content-type-options"] == "sniff" + + +def test_only_the_most_recent_response_for_an_endpoint_is_judged( + client: TestClient, recorder: Recorder +): + """An endpoint that was fixed stops being reported without deleting history.""" + asset, service, first = recorder.http_endpoint("https://127.0.0.1:8443", headers={}) + assert rules(recorder) == { + "hsts-not-set", + "content-type-options-not-nosniff", + "content-security-policy-not-set", + "frame-protection-not-set", + } + + later = recorder.discovery_run() + recorder.observation( + later, + "http_response", + "https://127.0.0.1:8443 returned HTTP 200", + asset=asset, + service=service, + detail={ + "status": 200, + "scheme": "https", + "headers_examined": list(SECURE_HEADERS) + ["location"], + "headers_present": sorted(SECURE_HEADERS), + }, + ) + for name, value in SECURE_HEADERS.items(): + recorder.observation( + later, + "http_header", + f"https://127.0.0.1:8443 reported {name}: {value}", + asset=asset, + service=service, + detail={"header": name, "value": value}, + confidence="reported", + ) + recorder.session.commit() + + assert rules(recorder) == set() + + +def test_severity_stays_restrained_for_hardening_headers(client: TestClient, recorder: Recorder): + recorder.http_endpoint("https://127.0.0.1:8443", headers={}) + findings = HttpSecurityHeaderDetector().detect(_context(recorder)) + + assert {str(finding.severity) for finding in findings} == {"low"} + assert {str(finding.confidence) for finding in findings} == {"high"} + + +def test_every_finding_cites_the_response_it_was_drawn_from( + client: TestClient, recorder: Recorder +): + recorder.http_endpoint("https://127.0.0.1:8443", headers={}) + for finding in HttpSecurityHeaderDetector().detect(_context(recorder)): + assert finding.evidence_observation_ids + + +def test_the_detector_is_registered_and_declares_what_it_reads(): + detector = next(item for item in available_detectors() if item.id == "http.security_headers") + assert detector.consumes == ("http_response", "http_header") diff --git a/backend/tests/test_schema_upgrade.py b/backend/tests/test_schema_upgrade.py index f8f3934..77c192b 100644 --- a/backend/tests/test_schema_upgrade.py +++ b/backend/tests/test_schema_upgrade.py @@ -1,4 +1,10 @@ -"""Phase 1 only adds tables, so an existing Phase 0 database upgrades in place.""" +"""Every phase so far only adds tables, so a deployed database upgrades in place. + +Each phase records the schema of the release before it as literal DDL and proves +that a database built from it survives `create_all` with its data intact and the +new phase working on top. That is the check that keeps "purely additive" a fact +rather than an intention. +""" import sqlite3 from pathlib import Path @@ -69,3 +75,246 @@ def test_every_phase_1_table_is_created(phase_0_database: Path): "discovery_runs", "evidence_records", } <= tables + + +#: The schema as released in v0.2.1, written out rather than derived, so a +#: change to the current models cannot quietly change what this test compares +#: against. +PHASE_1_SCHEMA = """ +CREATE TABLE dockyards ( + id INTEGER NOT NULL PRIMARY KEY, + name VARCHAR(120) NOT NULL, + description TEXT, + status VARCHAR(24) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL +); +CREATE TABLE scope_entries ( + id INTEGER NOT NULL PRIMARY KEY, + dockyard_id INTEGER NOT NULL, + rule VARCHAR(16) NOT NULL, + kind VARCHAR(16) NOT NULL, + value VARCHAR(255) NOT NULL, + note VARCHAR(255), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT uq_scope_entry UNIQUE (dockyard_id, rule, value), + FOREIGN KEY(dockyard_id) REFERENCES dockyards (id) ON DELETE CASCADE +); +CREATE TABLE assets ( + id INTEGER NOT NULL PRIMARY KEY, + dockyard_id INTEGER NOT NULL, + asset_type VARCHAR(16) NOT NULL, + identity VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + ip_address VARCHAR(45), + hostname VARCHAR(253), + first_seen DATETIME NOT NULL, + last_seen DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT uq_asset_identity UNIQUE (dockyard_id, asset_type, identity), + FOREIGN KEY(dockyard_id) REFERENCES dockyards (id) ON DELETE CASCADE +); +CREATE TABLE services ( + id INTEGER NOT NULL PRIMARY KEY, + asset_id INTEGER NOT NULL, + transport VARCHAR(8) NOT NULL, + port INTEGER NOT NULL, + state VARCHAR(16) NOT NULL, + service_name VARCHAR(64), + product VARCHAR(128), + version VARCHAR(64), + first_seen DATETIME NOT NULL, + last_seen DATETIME NOT NULL, + CONSTRAINT uq_service_socket UNIQUE (asset_id, transport, port), + FOREIGN KEY(asset_id) REFERENCES assets (id) ON DELETE CASCADE +); +CREATE TABLE discovery_runs ( + id INTEGER NOT NULL PRIMARY KEY, + dockyard_id INTEGER NOT NULL, + adapter VARCHAR(32) NOT NULL, + adapter_version VARCHAR(32) NOT NULL, + profile VARCHAR(32) NOT NULL, + requested_target VARCHAR(255) NOT NULL, + normalized_target VARCHAR(255), + status VARCHAR(16) NOT NULL, + decision VARCHAR(32) NOT NULL, + decision_reason VARCHAR(500) NOT NULL, + error VARCHAR(500), + asset_count INTEGER NOT NULL, + service_count INTEGER NOT NULL, + observation_count INTEGER NOT NULL, + evidence_path VARCHAR(255), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + started_at DATETIME, + completed_at DATETIME, + FOREIGN KEY(dockyard_id) REFERENCES dockyards (id) ON DELETE CASCADE +); +CREATE TABLE observations ( + id INTEGER NOT NULL PRIMARY KEY, + dockyard_id INTEGER NOT NULL, + discovery_run_id INTEGER, + asset_id INTEGER, + service_id INTEGER, + adapter VARCHAR(32) NOT NULL, + observation_type VARCHAR(32) NOT NULL, + summary VARCHAR(500) NOT NULL, + detail JSON, + confidence VARCHAR(16) NOT NULL, + raw_reference VARCHAR(255), + observed_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY(dockyard_id) REFERENCES dockyards (id) ON DELETE CASCADE, + FOREIGN KEY(discovery_run_id) REFERENCES discovery_runs (id) ON DELETE SET NULL, + FOREIGN KEY(asset_id) REFERENCES assets (id) ON DELETE CASCADE, + FOREIGN KEY(service_id) REFERENCES services (id) ON DELETE CASCADE +); +CREATE INDEX ix_observation_dockyard_time ON observations (dockyard_id, observed_at); +CREATE TABLE evidence_records ( + id INTEGER NOT NULL PRIMARY KEY, + dockyard_id INTEGER NOT NULL, + discovery_run_id INTEGER NOT NULL, + kind VARCHAR(16) NOT NULL, + relative_path VARCHAR(255) NOT NULL, + media_type VARCHAR(64) NOT NULL, + size_bytes INTEGER NOT NULL, + sha256 VARCHAR(64) NOT NULL, + truncated BOOLEAN NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY(dockyard_id) REFERENCES dockyards (id) ON DELETE CASCADE, + FOREIGN KEY(discovery_run_id) REFERENCES discovery_runs (id) ON DELETE CASCADE +); +""" + +#: One Dockyard as v0.2.1 would have left it: a probed HTTP origin whose +#: response predates RedDock recording which headers it examined, and a service +#: nmap identified. +PHASE_1_DATA = """ +INSERT INTO dockyards (id, name, description, status) +VALUES (1, 'Existing engagement', 'From 0.2.1', 'draft'); +INSERT INTO scope_entries (dockyard_id, rule, kind, value) +VALUES (1, 'include', 'ipv4', '127.0.0.1'); +INSERT INTO discovery_runs ( + id, dockyard_id, adapter, adapter_version, profile, requested_target, + normalized_target, status, decision, decision_reason, + asset_count, service_count, observation_count, evidence_path) +VALUES (1, 1, 'http', '1.0.0', 'http_probe', 'https://127.0.0.1:8443', + 'https://127.0.0.1:8443', 'completed', 'allowed', 'In scope', 1, 1, 2, '1/1'); +INSERT INTO assets ( + id, dockyard_id, asset_type, identity, display_name, ip_address, first_seen, last_seen) +VALUES (1, 1, 'web', 'https://127.0.0.1:8443', 'https://127.0.0.1:8443', + '127.0.0.1', '2026-08-01 12:00:00', '2026-08-01 12:00:00'); +INSERT INTO services (id, asset_id, transport, port, state, service_name, first_seen, last_seen) +VALUES (1, 1, 'tcp', 8443, 'open', 'https', '2026-08-01 12:00:00', '2026-08-01 12:00:00'); +INSERT INTO assets ( + id, dockyard_id, asset_type, identity, display_name, ip_address, first_seen, last_seen) +VALUES (2, 1, 'host', '127.0.0.1', '127.0.0.1', '127.0.0.1', + '2026-08-01 12:00:00', '2026-08-01 12:00:00'); +INSERT INTO services ( + id, asset_id, transport, port, state, service_name, product, version, first_seen, last_seen) +VALUES (2, 2, 'tcp', 23, 'open', 'telnet', 'Linux telnetd', NULL, + '2026-08-01 12:00:00', '2026-08-01 12:00:00'); +INSERT INTO observations ( + dockyard_id, discovery_run_id, asset_id, service_id, adapter, observation_type, + summary, detail, confidence, raw_reference, observed_at) +VALUES (1, 1, 1, 1, 'http', 'http_response', 'https://127.0.0.1:8443 returned HTTP 200', + '{"status": 200, "address": "127.0.0.1"}', 'observed', '1/1', '2026-08-01 12:00:00'); +INSERT INTO observations ( + dockyard_id, discovery_run_id, asset_id, service_id, adapter, observation_type, + summary, detail, confidence, raw_reference, observed_at) +VALUES (1, 1, 2, 2, 'nmap', 'service_identified', 'TCP/23 identified as Linux telnetd', + '{"name": "telnet", "product": "Linux telnetd"}', 'reported', '1/1', '2026-08-01 12:00:00'); +INSERT INTO evidence_records ( + dockyard_id, discovery_run_id, kind, relative_path, media_type, size_bytes, sha256, truncated) +VALUES (1, 1, 'normalized', 'normalized/result.json', 'application/json', 256, + '1111111111111111111111111111111111111111111111111111111111111111', 0); +""" + + +@pytest.fixture() +def phase_1_database(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + database = tmp_path / "reddock.db" + with sqlite3.connect(database) as connection: + connection.executescript(PHASE_1_SCHEMA) + connection.executescript(PHASE_1_DATA) + monkeypatch.setenv("REDDOCK_DATABASE_URL", f"sqlite:///{database}") + monkeypatch.setenv("REDDOCK_EVIDENCE_DIR", str(tmp_path / "evidence")) + import app.config + + app.config.get_settings.cache_clear() + import app.database + + app.database.configure_engine() + yield database + app.config.get_settings.cache_clear() + + +def test_every_phase_2_table_is_created(phase_1_database: Path): + import app.database + + app.database.initialize_database() + with sqlite3.connect(phase_1_database) as connection: + rows = connection.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = {row[0] for row in rows} + assert {"detection_runs", "findings", "finding_evidence"} <= tables + + +def test_phase_2_changes_no_phase_1_column(phase_1_database: Path): + """The upgrade is additive, so every Phase 1 table keeps the shape it had.""" + import app.database + + before = _columns(phase_1_database) + app.database.initialize_database() + after = _columns(phase_1_database) + + for table, columns in before.items(): + assert after[table] == columns, table + + +def test_phase_1_data_survives_and_phase_2_runs_on_top_of_it(phase_1_database: Path): + from fastapi.testclient import TestClient + + import app.database + import app.main + + app.database.initialize_database() + with TestClient(app.main.app) as client: + assert [row["name"] for row in client.get("/api/dockyards").json()] == [ + "Existing engagement" + ] + assert len(client.get("/api/dockyards/1/assets").json()) == 2 + assert len(client.get("/api/dockyards/1/observations").json()) == 2 + + run = client.post("/api/dockyards/1/detections", json={}) + assert run.status_code == 201, run.text + assert run.json()["status"] == "completed" + + findings = client.get("/api/dockyards/1/findings").json() + rules = {finding["rule_id"] for finding in findings} + + # A rule that needs only what v0.2.1 recorded still works... + assert "cleartext-remote-administration" in rules + # ...and one that needs to know which headers were examined stays silent + # rather than claiming a header was absent from a response nobody + # inspected for it. + assert not rules & { + "hsts-not-set", + "content-security-policy-not-set", + "content-type-options-not-nosniff", + "frame-protection-not-set", + } + + detail = client.get(f"/api/dockyards/1/findings/{findings[0]['id']}").json() + assert detail["evidence"][0]["sha256"] == "1" * 64 + + +def _columns(database: Path) -> dict[str, list[tuple]]: + with sqlite3.connect(database) as connection: + names = [ + row[0] + for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'") + ] + return { + name: list(connection.execute(f"PRAGMA table_info({name})")) # noqa: S608 - fixed names + for name in names + } diff --git a/backend/tests/test_service_detectors.py b/backend/tests/test_service_detectors.py new file mode 100644 index 0000000..064e7f0 --- /dev/null +++ b/backend/tests/test_service_detectors.py @@ -0,0 +1,288 @@ +"""Service rule and TLS certificate detector tests.""" + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app.detection.context import build_context +from app.detection.detectors.service_rules import ServiceRuleDetector +from app.detection.detectors.tls_certificates import TlsCertificateDetector +from app.detection.enrichment import CATALOG_SCHEMA, load_enrichment +from tests.phase1 import Recorder + + +def context(recorder: Recorder, enrichment=None): + recorder.session.commit() + return build_context(recorder.session, recorder.dockyard_id, enrichment=enrichment) + + +def service_findings(recorder: Recorder, enrichment=None): + return ServiceRuleDetector().detect(context(recorder, enrichment)) + + +class TestServiceRules: + def test_telnet_is_reported_as_cleartext_administration( + self, client: TestClient, recorder: Recorder + ): + recorder.identified_service("192.168.1.10", 23, service_name="telnet") + findings = service_findings(recorder) + + assert [finding.rule_id for finding in findings] == ["cleartext-remote-administration"] + assert str(findings[0].severity) == "high" + # The identification came from a banner, so this is not stated as certain. + assert str(findings[0].confidence) == "medium" + + def test_ftp_is_reported_without_claiming_auth_tls_was_tested( + self, client: TestClient, recorder: Recorder + ): + recorder.identified_service("192.168.1.10", 21, service_name="ftp") + finding = service_findings(recorder)[0] + + assert finding.rule_id == "cleartext-file-transfer" + assert str(finding.severity) == "medium" + assert "did not test whether AUTH TLS is required" in finding.description + + def test_a_service_with_no_rule_produces_no_finding( + self, client: TestClient, recorder: Recorder + ): + recorder.identified_service("192.168.1.10", 22, service_name="ssh") + assert service_findings(recorder) == () + + def test_a_port_number_alone_is_never_enough(self, client: TestClient, recorder: Recorder): + """TCP/23 open is TCP/23 open. Without an identification it says nothing.""" + asset = recorder.asset("192.168.1.10", asset_type="host") + recorder.service(asset, 23) + recorder.session.commit() + + assert service_findings(recorder) == () + + def test_a_service_identified_without_an_observation_produces_nothing( + self, client: TestClient, recorder: Recorder + ): + """Evidence is required, not preferred.""" + asset = recorder.asset("192.168.1.10", asset_type="host") + recorder.service(asset, 23, service_name="telnet") + recorder.session.commit() + + assert service_findings(recorder) == () + + def test_a_disclosed_version_is_informational_not_a_vulnerability( + self, client: TestClient, recorder: Recorder + ): + recorder.identified_service( + "192.168.1.10", 22, service_name="ssh", product="OpenSSH", version="7.2p2" + ) + finding = service_findings(recorder)[0] + + assert finding.rule_id == "service-version-disclosed" + assert str(finding.severity) == "informational" + assert finding.cve_references == () + + def test_a_product_without_a_version_discloses_nothing_to_report( + self, client: TestClient, recorder: Recorder + ): + recorder.identified_service( + "192.168.1.10", 22, service_name="ssh", product="OpenSSH", version=None + ) + assert service_findings(recorder) == () + + +class TestCveEnrichment: + @pytest.fixture() + def catalog(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + path = tmp_path / "catalog.json" + path.write_text( + json.dumps( + { + "schema": CATALOG_SCHEMA, + "source": "lab-catalogue", + "version": "2026-08-01", + "entries": [ + { + "product": "OpenSSH", + "version": "7.2p2", + "cve": ["CVE-2016-6515", "CVE-2016-6210"], + "url": "https://example.invalid/openssh-7.2p2", + } + ], + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("REDDOCK_CVE_CATALOG", str(path)) + import app.config + + app.config.get_settings.cache_clear() + yield path + app.config.get_settings.cache_clear() + + def test_no_catalogue_means_no_enrichment_and_no_error(self, client: TestClient): + enrichment, warning = load_enrichment() + assert enrichment.available is False + assert warning is None + assert enrichment.lookup("OpenSSH", "7.2p2") == () + + def test_an_exact_product_and_version_match_is_attached_with_provenance( + self, client: TestClient, recorder: Recorder, catalog: Path + ): + recorder.identified_service( + "192.168.1.10", 22, service_name="ssh", product="OpenSSH", version="7.2p2" + ) + enrichment, warning = load_enrichment() + assert warning is None + + finding = service_findings(recorder, enrichment)[0] + assert [reference.cve_id for reference in finding.cve_references] == [ + "CVE-2016-6210", + "CVE-2016-6515", + ] + assert finding.cve_references[0].source == "lab-catalogue" + assert finding.cve_references[0].source_version == "2026-08-01" + assert finding.cve_references[0].match_type == "exact_version" + + def test_enrichment_never_changes_severity( + self, client: TestClient, recorder: Recorder, catalog: Path + ): + """A CVE association is not a test result, so it cannot raise a rating.""" + recorder.identified_service( + "192.168.1.10", 22, service_name="ssh", product="OpenSSH", version="7.2p2" + ) + enrichment, _ = load_enrichment() + + enriched = service_findings(recorder, enrichment)[0] + plain = service_findings(recorder)[0] + assert str(enriched.severity) == str(plain.severity) == "informational" + assert str(enriched.confidence) == str(plain.confidence) + + def test_the_description_says_an_association_is_not_a_test_result( + self, client: TestClient, recorder: Recorder, catalog: Path + ): + recorder.identified_service( + "192.168.1.10", 22, service_name="ssh", product="OpenSSH", version="7.2p2" + ) + enrichment, _ = load_enrichment() + finding = service_findings(recorder, enrichment)[0] + + assert "not a test result" in finding.description + assert "did not check whether this service is affected" in finding.description + + def test_a_different_version_is_not_matched( + self, client: TestClient, recorder: Recorder, catalog: Path + ): + recorder.identified_service( + "192.168.1.10", 22, service_name="ssh", product="OpenSSH", version="9.6p1" + ) + enrichment, _ = load_enrichment() + + assert service_findings(recorder, enrichment)[0].cve_references == () + + def test_matching_ignores_casing_and_spacing_only(self, client: TestClient, catalog: Path): + enrichment, _ = load_enrichment() + assert enrichment.lookup("openssh", "7.2P2") + assert enrichment.lookup("OpenSSH", "7.2") == () + + def test_a_missing_catalogue_is_a_warning_not_a_failure( + self, client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setenv("REDDOCK_CVE_CATALOG", str(tmp_path / "absent.json")) + import app.config + + app.config.get_settings.cache_clear() + try: + enrichment, warning = load_enrichment() + finally: + app.config.get_settings.cache_clear() + + assert enrichment.available is False + assert warning is not None and "does not exist" in warning + + def test_a_malformed_catalogue_is_a_warning_not_a_failure( + self, client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + broken = tmp_path / "broken.json" + broken.write_text("{ not json", encoding="utf-8") + monkeypatch.setenv("REDDOCK_CVE_CATALOG", str(broken)) + import app.config + + app.config.get_settings.cache_clear() + try: + enrichment, warning = load_enrichment() + finally: + app.config.get_settings.cache_clear() + + assert enrichment.available is False + assert warning is not None and "was not loaded" in warning + + def test_a_catalogue_without_the_expected_schema_is_refused( + self, client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + wrong = tmp_path / "wrong.json" + wrong.write_text(json.dumps({"entries": []}), encoding="utf-8") + monkeypatch.setenv("REDDOCK_CVE_CATALOG", str(wrong)) + import app.config + + app.config.get_settings.cache_clear() + try: + enrichment, warning = load_enrichment() + finally: + app.config.get_settings.cache_clear() + + assert enrichment.available is False + assert "schema" in (warning or "") + + +class TestTlsCertificates: + def test_a_verified_certificate_produces_no_finding( + self, client: TestClient, recorder: Recorder + ): + recorder.tls_endpoint( + "https://127.0.0.1:8443", + tls={"verified": True, "version": "TLSv1.3", "certificate_sha256": "b" * 64}, + ) + assert TlsCertificateDetector().detect(context(recorder)) == () + + def test_an_expired_certificate_is_reported_specifically( + self, client: TestClient, recorder: Recorder + ): + recorder.tls_endpoint( + "https://127.0.0.1:8443", + tls={ + "verified": False, + "version": "TLSv1.3", + "verify_code": 10, + "verify_message": "certificate has expired", + "certificate_sha256": "c" * 64, + }, + ) + findings = TlsCertificateDetector().detect(context(recorder)) + + assert [finding.rule_id for finding in findings] == ["certificate-expired"] + assert str(findings[0].severity) == "medium" + + def test_another_verification_failure_reports_the_reason_it_was_given( + self, client: TestClient, recorder: Recorder + ): + recorder.tls_endpoint( + "https://127.0.0.1:8443", + tls={ + "verified": False, + "version": "TLSv1.3", + "verify_code": 18, + "verify_message": "self signed certificate", + "certificate_sha256": "d" * 64, + }, + ) + finding = TlsCertificateDetector().detect(context(recorder))[0] + + assert finding.rule_id == "certificate-not-trusted" + assert str(finding.severity) == "low" + assert "self signed certificate" in finding.description + assert finding.detail["certificate_sha256"] == "d" * 64 + + def test_a_session_recorded_without_a_verification_outcome_says_nothing( + self, client: TestClient, recorder: Recorder + ): + recorder.tls_endpoint("https://127.0.0.1:8443", tls={"version": "TLSv1.3"}) + assert TlsCertificateDetector().detect(context(recorder)) == () diff --git a/docs/adr/0006-detection-boundary.md b/docs/adr/0006-detection-boundary.md new file mode 100644 index 0000000..3ec5e86 --- /dev/null +++ b/docs/adr/0006-detection-boundary.md @@ -0,0 +1,13 @@ +# ADR 0006: A detector concludes; it never reaches + +**Status:** Accepted + +Phase 2 introduces findings, and the risk it introduces with them is that a component allowed to interpret data starts wanting to go and get more of it. The detection boundary is therefore deliberately weaker than the discovery adapter boundary rather than parallel to it. + +A discovery adapter may contact a target, after DockGuard allows it. A detector may not contact anything. It receives a frozen snapshot of one Dockyard's recorded assets, services and observations and returns value objects. It is given no database session, no socket, no subprocess, no target string and no operator-supplied option, so there is nothing for it to widen, execute or reach, and it needs no scope decision because it reaches nothing. This is enforced structurally: the detector modules are parsed in the test suite and refused if they import anything that could reach outside the process or touch the database. + +Everything that could be got wrong belongs to the runner rather than the detector. The runner builds the snapshot, validates the output, computes identity, reconciles against what is already known, resolves what is no longer reproduced and writes evidence. A detector that returns something malformed is failed as a whole and its results are discarded, because a component that has demonstrated it is wrong about its own output is not one to half-believe, and a detector that failed resolves nothing — not running is not evidence that an issue went away. + +Two invariants make a finding checkable rather than merely stated. A finding must cite at least one observation from the snapshot it was drawn from, and a finding with no evidence is refused rather than stored with a caveat. Identity is a SHA-256 fingerprint over the detector, the rule and the asset and service concerned, so the same issue stays one record across runs, restarts and processes; Python's randomized `hash()` would make every finding look new after a restart. + +A finding is never deleted. One that a later successful run no longer reproduces is resolved and kept, because the record that it was once true is part of what an assessment is for. diff --git a/docs/adr/0007-cve-enrichment-is-an-association.md b/docs/adr/0007-cve-enrichment-is-an-association.md new file mode 100644 index 0000000..21d7241 --- /dev/null +++ b/docs/adr/0007-cve-enrichment-is-an-association.md @@ -0,0 +1,15 @@ +# ADR 0007: A CVE association is enrichment, not a conclusion + +**Status:** Accepted + +RedDock does not fetch CVE data. There is no vulnerability feed, no scheduled download, and no network dependency at startup or during a detection run. What Phase 2 ships is the boundary — a detector may ask whether a catalogue associates an observed product and version with published CVE identifiers — behind which an operator may place a local JSON catalogue through `REDDOCK_CVE_CATALOG`. + +The reason to keep it this small is that version-based CVE matching is the point where assessment tools usually start inventing results. A banner is something a service said about itself; a catalogue entry is something someone wrote down; neither is a test. So three rules hold. + +An association never creates a finding. It is attached to a finding that already stood on its own evidence — that a service disclosed its product and version, recorded at informational severity because disclosure is not a weakness — and it never changes that finding's severity, confidence or status. A test asserts that the same data produces the same rating with and without a catalogue loaded. + +Only exact matches are reported. RedDock compares a normalized product name and an identical version string and reports the match as `exact_version`. It does not interpret version ranges, because a range is an inference and an inference presented beside a CVE identifier reads as a result. + +Absence is not failure. With no catalogue configured, enrichment is unavailable and detection produces exactly the same findings. A catalogue that is missing, oversized, unreadable or malformed is recorded as a warning on the detection run rather than failing it, and each reference carries the source, the source version and what it matched, so a reviewer can see where the claim came from. + +The limitation is deliberate and should be stated rather than closed quietly: RedDock ships no CVE data, so out of the box no finding carries a CVE reference. Distributing or synchronising a vulnerability database is a larger decision than this milestone, and shipping a partial one would produce exactly the misleading confidence this architecture exists to avoid. diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index f3923c8..f115566 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -1,7 +1,7 @@ # Screenshots -`dashboard.png` and `workspace.png` are real, scrubbed captures of RedDock running locally against loopback. When replacing them, use an empty Dockyard list or clearly fictional local sample data; do not capture host paths, browser tabs, personal information, or authorized-engagement details. +`dashboard.png`, `workspace.png`, `detection.png`, and `findings.png` are real, scrubbed captures of RedDock running locally against loopback. When replacing them, use an empty Dockyard list or clearly fictional local sample data; do not capture host paths, browser tabs, personal information, or authorized-engagement details. -The current captures use two fictional workspaces, a loopback scope, and a deliberately out-of-scope target so the DockGuard denial is visible. +The current captures use two fictional workspaces, a loopback scope, and a deliberately out-of-scope target so the DockGuard denial is visible. The findings shown are produced by RedDock's own detectors against RedDock's own origin inside the container, so nothing outside the machine was contacted to make them. The README intentionally does not present a mockup as a product screenshot. diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png index d0b21e6..7005fc0 100644 Binary files a/docs/screenshots/dashboard.png and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/detection.png b/docs/screenshots/detection.png new file mode 100644 index 0000000..09b16c9 Binary files /dev/null and b/docs/screenshots/detection.png differ diff --git a/docs/screenshots/findings.png b/docs/screenshots/findings.png new file mode 100644 index 0000000..305c723 Binary files /dev/null and b/docs/screenshots/findings.png differ diff --git a/docs/screenshots/workspace.png b/docs/screenshots/workspace.png index 90488e4..6498124 100644 Binary files a/docs/screenshots/workspace.png and b/docs/screenshots/workspace.png differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1c79b89..c4d4c98 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "reddock-frontend", - "version": "0.1.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "reddock-frontend", - "version": "0.1.0", + "version": "0.3.0", "dependencies": { "@vitejs/plugin-react": "4.4.1", "react": "19.0.0", diff --git a/frontend/package.json b/frontend/package.json index 168f828..7c4440a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "reddock-frontend", "private": true, - "version": "0.2.1", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 1c149fc..8dfcef9 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -26,6 +26,16 @@ const adapters = [ }, ]; +const detectors = [ + { + id: "http.security_headers", + version: "1.0.0", + title: "HTTP security headers", + description: "Reports response-level protections the recorded response did not carry.", + consumes: ["http_response", "http_header"], + }, +]; + const scopeEntry = { id: 7, rule: "include", @@ -47,6 +57,94 @@ const asset = { service_count: 1, }; +const observation = { + id: 11, + discovery_run_id: 2, + asset_id: 3, + service_id: 4, + adapter: "http", + observation_type: "http_response", + summary: "https://127.0.0.1:8443 returned HTTP 200", + detail: { status: 200 }, + confidence: "observed", + raw_reference: "1/2", + observed_at: "2026-08-18T12:30:00Z", +}; + +const finding = { + id: 9, + fingerprint: "b8b1e0a2f4c6d8e0b8b1e0a2f4c6d8e0b8b1e0a2f4c6d8e0b8b1e0a2f4c6d8e0", + detector: "http.security_headers", + detector_version: "1.0.0", + rule_id: "hsts-not-set", + title: "https://127.0.0.1:8443 does not set Strict-Transport-Security", + category: "hardening", + severity: "low", + confidence: "high", + status: "open", + status_note: null, + asset_id: 3, + service_id: 4, + first_seen: "2026-08-18T12:30:00Z", + last_seen: "2026-08-18T12:30:00Z", + resolved_at: null, + first_detection_run_id: 5, + last_detection_run_id: 5, + cve_references: [], + asset_label: "https://127.0.0.1:8443", + service_endpoint: "TCP/8443", + evidence_count: 1, +}; + +const findingDetail = { + ...finding, + description: "The HTTPS response carried no Strict-Transport-Security header.", + remediation: "Send Strict-Transport-Security with a max-age the operator can commit to.", + detail: { status: 200 }, + evidence: [ + { + id: 21, + observation_id: 11, + discovery_run_id: 2, + detection_run_id: 5, + evidence_record_id: 31, + summary: "https://127.0.0.1:8443 returned HTTP 200", + created_at: "2026-08-18T12:30:00Z", + evidence_path: "normalized/result.json", + sha256: "a".repeat(64), + }, + ], +}; + +const detectionRun = { + id: 5, + dockyard_id: 1, + status: "completed", + detectors: [ + { + id: "http.security_headers", + version: "1.0.0", + status: "completed", + findings: 1, + error: null, + }, + ], + enrichment: { id: "none", version: null, available: false, warning: null }, + asset_count: 1, + service_count: 1, + observation_count: 3, + finding_count: 1, + new_finding_count: 1, + resolved_finding_count: 0, + error: null, + evidence_path: "1/detection/5", + metadata_sha256: "b".repeat(64), + result_sha256: "c".repeat(64), + created_at: "2026-08-18T12:31:00Z", + started_at: "2026-08-18T12:31:00Z", + completed_at: "2026-08-18T12:31:01Z", +}; + const allowed = { decision: "allowed", target: "127.0.0.1", @@ -69,40 +167,87 @@ const denied = { allowed: false, }; -type Options = { scope?: unknown[]; assets?: unknown[]; evaluation?: unknown }; +type Options = { + scope?: unknown[]; + assets?: unknown[]; + evaluation?: unknown; + findings?: unknown[]; + observations?: unknown[]; +}; + +type Calls = { + discovery: ReturnType; + detection: ReturnType; + decision: ReturnType; +}; -function stubApi({ scope = [scopeEntry], assets = [], evaluation = allowed }: Options = {}) { - const started = vi.fn(); +function stubApi({ + scope = [scopeEntry], + assets = [], + evaluation = allowed, + findings = [], + observations = [], +}: Options = {}): Calls { + const calls: Calls = { discovery: vi.fn(), detection: vi.fn(), decision: vi.fn() }; vi.stubGlobal( "fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); + const url = new URL(String(input), "http://localhost"); + const path = url.pathname; const json = (body: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(body), { status })); - if (url.endsWith("/health")) return json({ status: "healthy", service: "reddock-core" }); - if (url.endsWith("/version")) - return json({ name: "RedDock", version: "0.2.1", phase: "Phase 1 — Discovery" }); - if (url.endsWith("/adapters")) return json(adapters); - if (url.endsWith("/scope/evaluate")) return json(evaluation); - if (url.endsWith("/scope")) return json(scope); - if (url.endsWith("/assets")) return json(assets); - if (url.endsWith("/services")) return json([]); - if (url.endsWith("/observations")) return json([]); - if (url.endsWith("/evidence")) return json([]); - if (url.endsWith("/discoveries")) { + if (path.endsWith("/health")) return json({ status: "healthy", service: "reddock-core" }); + if (path.endsWith("/version")) + return json({ name: "RedDock", version: "0.3.0", phase: "Phase 2 — Detection" }); + if (path.endsWith("/adapters")) return json(adapters); + if (path.endsWith("/detectors")) return json(detectors); + if (path.endsWith("/scope/evaluate")) return json(evaluation); + if (path.endsWith("/scope")) return json(scope); + if (path.endsWith("/assets")) return json(assets); + if (path.endsWith("/services")) return json([]); + if (path.endsWith("/observations")) return json(observations); + if (path.endsWith("/evidence")) return json([]); + if (/\/findings\/\d+$/.test(path)) { + if (init?.method === "PATCH") { + const body = JSON.parse(String(init.body)); + calls.decision(body); + return json({ ...findingDetail, status: body.status, status_note: body.note }); + } + return json(findingDetail); + } + if (path.endsWith("/findings")) { + const severity = url.searchParams.get("severity"); + const status = url.searchParams.get("status"); + return json( + findings.filter((row) => { + const item = row as { severity: string; status: string }; + return ( + (!severity || item.severity === severity) && (!status || item.status === status) + ); + }), + ); + } + if (path.endsWith("/detections")) { + if (init?.method === "POST") { + calls.detection(JSON.parse(String(init.body))); + return json(detectionRun, 201); + } + return json(findings.length ? [detectionRun] : []); + } + if (path.endsWith("/discoveries")) { if (init?.method === "POST") { - started(JSON.parse(String(init.body))); + calls.discovery(JSON.parse(String(init.body))); return json({ id: 5, dockyard_id: 1, status: "pending" }, 202); } return json([]); } - if (url.endsWith("/dockyards") && init?.method === "POST") + if (path.endsWith("/dockyards") && init?.method === "POST") return json({ ...dockyard, id: 2, name: JSON.parse(String(init.body)).name }, 201); - return json([dockyard]); + return json([dockyard, { ...dockyard, id: 2, name: "Second workspace" }]); }), ); - return started; + return calls; } async function openWorkspace(user: ReturnType) { @@ -123,7 +268,7 @@ describe("RedDock application", () => { it("shows healthy status and the current phase", async () => { render(); expect(await screen.findByText("Healthy")).toBeInTheDocument(); - expect(screen.getByText("PHASE 1 — DISCOVERY")).toBeInTheDocument(); + expect(screen.getByText("PHASE 2 — DETECTION")).toBeInTheDocument(); expect(screen.getByText("Lab review")).toBeInTheDocument(); }); @@ -152,7 +297,7 @@ describe("RedDock application", () => { }); it("keeps discovery unavailable until DockGuard allows the target", async () => { - const started = stubApi({ evaluation: denied }); + const calls = stubApi({ evaluation: denied }); const user = userEvent.setup(); render(); await screen.findByText("Lab review"); @@ -167,11 +312,11 @@ describe("RedDock application", () => { expect(await screen.findByText("DENIED")).toBeInTheDocument(); expect(run).toBeDisabled(); - expect(started).not.toHaveBeenCalled(); + expect(calls.discovery).not.toHaveBeenCalled(); }); it("launches discovery once the target is allowed", async () => { - const started = stubApi(); + const calls = stubApi(); const user = userEvent.setup(); render(); await screen.findByText("Lab review"); @@ -187,7 +332,7 @@ describe("RedDock application", () => { await user.click(run); await waitFor(() => - expect(started).toHaveBeenCalledWith({ + expect(calls.discovery).toHaveBeenCalledWith({ target: "127.0.0.1", adapter: "nmap", profile: "host_discovery", @@ -208,11 +353,167 @@ describe("RedDock application", () => { expect(within(table).queryByText(/critical|high|severity/i)).toBeNull(); }); - it("keeps Phase 2 capabilities visibly planned", async () => { + it("keeps unbuilt capabilities visibly planned", async () => { const user = userEvent.setup(); render(); await screen.findByText("Lab review"); - await user.click(screen.getAllByRole("button", { name: /Findings/ })[0]); - expect(await screen.findByText("Findings is not available yet.")).toBeInTheDocument(); + await user.click(screen.getAllByRole("button", { name: /RedPath/ })[0]); + expect(await screen.findByText("RedPath is not available yet.")).toBeInTheDocument(); + }); +}); + +describe("Phase 2 detection", () => { + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("counts open findings on the dashboard", async () => { + stubApi({ findings: [finding] }); + render(); + + expect(await screen.findByText("Open findings")).toBeInTheDocument(); + expect( + screen.getByText("Produced by a detector, from recorded observations"), + ).toBeInTheDocument(); + }); + + it("lists findings with severity, confidence and status stated separately", async () => { + stubApi({ findings: [finding] }); + const user = userEvent.setup(); + render(); + await screen.findByText("Lab review"); + await user.click(screen.getAllByRole("button", { name: /^Findings/ })[0]); + + const table = await screen.findByRole("table"); + const headers = within(table) + .getAllByRole("columnheader") + .map((cell) => cell.textContent); + expect(headers).toEqual([ + "Finding", + "Severity", + "Confidence", + "Status", + "Affected", + "Detector", + "Seen", + ]); + expect(within(table).getByText("low")).toBeInTheDocument(); + expect(within(table).getByText("High")).toBeInTheDocument(); + expect(within(table).getByText("Open")).toBeInTheDocument(); + expect(within(table).getByText(/last seen/)).toBeInTheDocument(); + expect(within(table).getByText("https://127.0.0.1:8443")).toBeInTheDocument(); + }); + + it("presents no risk score or aggregate rating", async () => { + stubApi({ findings: [finding] }); + const user = userEvent.setup(); + render(); + await screen.findByText("Lab review"); + await user.click(screen.getAllByRole("button", { name: /^Findings/ })[0]); + await screen.findByRole("table"); + + expect(screen.queryByText(/risk score|cvss|overall rating|\d+\s*\/\s*10/i)).toBeNull(); + }); + + it("shows the detector, the observation and the hash behind a finding", async () => { + stubApi({ findings: [finding] }); + const user = userEvent.setup(); + render(); + await screen.findByText("Lab review"); + await user.click(screen.getAllByRole("button", { name: /^Findings/ })[0]); + await user.click(await screen.findByRole("button", { name: finding.title })); + + expect(await screen.findByText(findingDetail.description)).toBeInTheDocument(); + expect(screen.getByText("hsts-not-set")).toBeInTheDocument(); + expect(screen.getAllByText("http.security_headers").length).toBeGreaterThan(0); + expect(screen.getByText(/Observation #11/)).toBeInTheDocument(); + expect(screen.getByText(/discovery run #2/)).toBeInTheDocument(); + expect(screen.getByText(/normalized\/result\.json · aaaaaaaaaaaaaaaa…/)).toBeInTheDocument(); + }); + + it("records an operator decision without deleting the finding", async () => { + const calls = stubApi({ findings: [finding] }); + const user = userEvent.setup(); + render(); + await screen.findByText("Lab review"); + await user.click(screen.getAllByRole("button", { name: /^Findings/ })[0]); + await user.click(await screen.findByRole("button", { name: finding.title })); + await user.click(await screen.findByRole("button", { name: "Suppressed" })); + + await waitFor(() => + expect(calls.decision).toHaveBeenCalledWith({ status: "suppressed", note: null }), + ); + expect(screen.queryByRole("button", { name: "Resolved" })).toBeNull(); + }); + + it("closes a finding when the Dockyard changes", async () => { + stubApi({ findings: [finding] }); + const user = userEvent.setup(); + render(); + await screen.findByText("Lab review"); + await user.click(screen.getAllByRole("button", { name: /^Findings/ })[0]); + await user.click(await screen.findByRole("button", { name: finding.title })); + expect(await screen.findByText(findingDetail.description)).toBeInTheDocument(); + + await user.selectOptions(screen.getByLabelText("Dockyard"), "2"); + await waitFor(() => expect(screen.queryByText(findingDetail.description)).toBeNull()); + }); + + it("filters findings by severity through the API", async () => { + stubApi({ findings: [finding] }); + const user = userEvent.setup(); + render(); + await screen.findByText("Lab review"); + await user.click(screen.getAllByRole("button", { name: /^Findings/ })[0]); + await screen.findByRole("table"); + + await user.selectOptions(screen.getByLabelText("Severity"), "critical"); + await waitFor(() => + expect( + screen.getByText(/No findings match this view/), + ).toBeInTheDocument(), + ); + }); + + it("runs detection from the Dockyard workspace without sending a target", async () => { + const calls = stubApi({ findings: [finding], observations: [observation] }); + const user = userEvent.setup(); + render(); + await screen.findByText("Lab review"); + await openWorkspace(user); + await user.click(await screen.findByRole("button", { name: "Detection" })); + + expect(await screen.findByText("HTTP security headers")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Run detection" })); + + await waitFor(() => expect(calls.detection).toHaveBeenCalledWith({})); + }); + + it("shows detection runs with what they read and what they produced", async () => { + stubApi({ findings: [finding], observations: [observation] }); + const user = userEvent.setup(); + render(); + await screen.findByText("Lab review"); + await openWorkspace(user); + await user.click(await screen.findByRole("button", { name: "Detection" })); + + expect(await screen.findByText("Detection runs")).toBeInTheDocument(); + expect(screen.getByText(/1 assets · 3 observations/)).toBeInTheDocument(); + expect(screen.getByText(/1 produced · 1 new · 0 resolved/)).toBeInTheDocument(); + expect(screen.getByText(/cccccccccccccccc…/)).toBeInTheDocument(); + }); + + it("keeps observations described as records rather than findings", async () => { + stubApi({ observations: [observation] }); + const user = userEvent.setup(); + render(); + await screen.findByText("Lab review"); + await openWorkspace(user); + await user.click(await screen.findByRole("button", { name: "Observations" })); + + expect( + await screen.findByText(/It carries no severity and no verdict/), + ).toBeInTheDocument(); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bbfbc1c..c3bd419 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,14 +8,17 @@ import { Planned, StatusPill, } from "./components"; +import { FindingsPanel } from "./Findings"; import { formatBytes, formatDate } from "./format"; import { AssetTable, Workspace } from "./Workspace"; import type { Adapter, Asset, + Detector, DiscoveryRun, Dockyard, EvidenceRecord, + Finding, Health, Version, } from "./types"; @@ -41,13 +44,20 @@ const pages: Page[] = [ "Settings", ]; -// Phase 1 activates discovery and evidence; detection and correlation are not built. -const availablePages = new Set(["Dashboard", "Dockyards", "Assets", "RedLedger"]); +// Phase 2 activates detection and findings; correlation and reporting are not built. +const availablePages = new Set([ + "Dashboard", + "Dockyards", + "Assets", + "Findings", + "RedLedger", +]); export function App() { const [page, setPage] = useState("Dashboard"); const [dockyards, setDockyards] = useState([]); const [adapters, setAdapters] = useState([]); + const [detectors, setDetectors] = useState([]); const [health, setHealth] = useState(null); const [version, setVersion] = useState(null); const [error, setError] = useState(null); @@ -55,16 +65,19 @@ export function App() { const refresh = useCallback(async () => { try { - const [nextHealth, nextVersion, nextDockyards, nextAdapters] = await Promise.all([ - api.health(), - api.version(), - api.dockyards(), - api.adapters(), - ]); + const [nextHealth, nextVersion, nextDockyards, nextAdapters, nextDetectors] = + await Promise.all([ + api.health(), + api.version(), + api.dockyards(), + api.adapters(), + api.detectors(), + ]); setHealth(nextHealth); setVersion(nextVersion); setDockyards(nextDockyards); setAdapters(nextAdapters); + setDetectors(nextDetectors); setError(null); } catch { setError("RedDock Core is unavailable. Check the container status and try again."); @@ -130,7 +143,7 @@ export function App() {

{page}

- {(version?.phase ?? "Phase 1 — Discovery").toUpperCase()} + {(version?.phase ?? "Phase 2 — Detection").toUpperCase()} {error && ( @@ -151,6 +164,7 @@ export function App() { setSelected(null)} onError={setError} /> @@ -158,6 +172,7 @@ export function App() { ))} {page === "Assets" && } + {page === "Findings" && } {page === "RedLedger" && } {!availablePages.has(page) && } @@ -178,6 +193,7 @@ function Dashboard({ }) { const [runs, setRuns] = useState([]); const [assetCount, setAssetCount] = useState(0); + const [findings, setFindings] = useState([]); useEffect(() => { if (!dockyards.length) return; @@ -187,6 +203,9 @@ function Dashboard({ Promise.all(dockyards.map((dockyard) => api.assets(dockyard.id))) .then((results) => setAssetCount(results.flat().length)) .catch(() => onError("Could not load the asset inventory.")); + Promise.all(dockyards.map((dockyard) => api.findings(dockyard.id, { status: "open" }))) + .then((results) => setFindings(results.flat())) + .catch(() => onError("Could not load open findings.")); }, [dockyards, onError]); return ( @@ -194,10 +213,10 @@ function Dashboard({

AUTHORIZED ASSESSMENT WORKSPACE

-

Scoped discovery, with evidence for every observation.

+

Scoped discovery, with evidence for every finding.

RedDock is online and limited to non-invasive discovery. Every target passes DockGuard - before a tool runs. + before a tool runs, and detection reads only what was already recorded.

@@ -388,6 +408,38 @@ function AssetsPage({ ); } +function FindingsPage({ + dockyards, + onError, +}: { + dockyards: Dockyard[]; + onError: (message: string | null) => void; +}) { + const [selected, setSelected] = useState(null); + + useEffect(() => { + if (selected === null && dockyards.length) setSelected(dockyards[0].id); + }, [dockyards, selected]); + + if (!dockyards.length) { + return ( +
+ +
+ ); + } + return ( + <> +
+ +
+ {selected !== null && ( + + )} + + ); +} + function LedgerPage({ dockyards, onError, @@ -416,8 +468,10 @@ function LedgerPage({

- Phase 1 retains the raw tool output, the normalized result and a metadata record for every - discovery run, each hashed with SHA-256. The full RedLedger experience arrives later. + RedDock retains the raw tool output, the normalized result and a metadata record for every + discovery run, each hashed with SHA-256. A detection run retains its own normalized result + and metadata under the same evidence root, and every finding names the observations and + hashes behind it. The full RedLedger experience arrives later.

{rows.length ? ( diff --git a/frontend/src/Findings.tsx b/frontend/src/Findings.tsx new file mode 100644 index 0000000..f5ef8f0 --- /dev/null +++ b/frontend/src/Findings.tsx @@ -0,0 +1,438 @@ +import { useCallback, useEffect, useState } from "react"; +import { api } from "./api"; +import { DataTable, EmptyState, StatusPill } from "./components"; +import { formatCompact, formatDate, humanize } from "./format"; +import type { DetectionRun, Detector, Finding, FindingDetail } from "./types"; + +const SEVERITIES = ["critical", "high", "medium", "low", "informational"] as const; +const STATUSES = ["open", "resolved", "suppressed", "accepted"] as const; +/** An operator states a decision; whether an issue is still there is not one. */ +const DECISIONS = ["open", "suppressed", "accepted"] as const; + +export function SeverityTag({ severity }: { severity: string }) { + return {severity}; +} + +export function FindingsPanel({ + dockyardId, + refreshKey, + onError, +}: { + dockyardId: number; + refreshKey: number; + onError: (message: string | null) => void; +}) { + const [findings, setFindings] = useState([]); + const [severity, setSeverity] = useState(""); + const [status, setStatus] = useState(""); + const [selected, setSelected] = useState(null); + + const load = useCallback(async () => { + try { + setFindings(await api.findings(dockyardId, { severity, status })); + onError(null); + } catch (error) { + onError(error instanceof Error ? error.message : "Could not load findings."); + } + }, [dockyardId, severity, status, onError]); + + useEffect(() => { + void load(); + }, [load, refreshKey]); + + // A finding belongs to one Dockyard. Changing workspace must not leave the + // previous one's finding open beside another workspace's list. + useEffect(() => { + setSelected(null); + }, [dockyardId]); + + async function open(finding: Finding) { + try { + setSelected(await api.finding(dockyardId, finding.id)); + } catch (error) { + onError(error instanceof Error ? error.message : "Could not load this finding."); + } + } + + async function decide(finding: FindingDetail, next: string) { + try { + setSelected(await api.updateFinding(dockyardId, finding.id, next)); + await load(); + } catch (error) { + onError(error instanceof Error ? error.message : "Could not update this finding."); + } + } + + return ( +
+
+
+
+

DETECTION RESULTS

+

Findings

+
+ {findings.length} +
+

+ A finding is a normalized conclusion a named detector drew from recorded observations, + and it carries the observations that support it. An observation on its own is still only + a record of what was seen. +

+
+ + +
+ {findings.length ? ( + + {findings.map((finding) => ( + void open(finding)} + > + + + + + + + {humanize(finding.confidence)} + + + + + {finding.asset_label ?? "—"} + {finding.service_endpoint && ( +
+ {finding.service_endpoint} +
+ )} + + + {finding.detector} + + + {formatCompact(finding.first_seen)} + last seen {formatCompact(finding.last_seen)} + + + ))} +
+ ) : ( + + )} +
+ +
+

FINDING

+ {selected ? ( + + ) : ( + <> +

Select a finding

+ + + )} +
+
+ ); +} + +function FindingDetailView({ + finding, + onDecide, +}: { + finding: FindingDetail; + onDecide: (finding: FindingDetail, status: string) => Promise; +}) { + return ( + <> +

{finding.title}

+
+ + {humanize(finding.confidence)} confidence + +
+

{finding.description}

+ {finding.remediation && ( + <> +

REMEDIATION

+

{finding.remediation}

+ + )} +
+
+
Detector
+
+ + {finding.detector} {finding.detector_version} + {" "} + · rule {finding.rule_id} +
+
+
+
Affected
+
+ {finding.asset_label ?? "—"} + {finding.service_endpoint ? ` · ${finding.service_endpoint}` : ""} +
+
+
+
First seen
+
{formatDate(finding.first_seen)}
+
+
+
Last seen
+
{formatDate(finding.last_seen)}
+
+ {finding.resolved_at && ( +
+
Resolved
+
{formatDate(finding.resolved_at)}
+
+ )} +
+
Fingerprint
+
+ {finding.fingerprint.slice(0, 24)}… +
+
+ {finding.status_note && ( +
+
Operator note
+
{finding.status_note}
+
+ )} +
+ + {finding.cve_references.length > 0 && ( +
+

CVE references

+

+ A catalogue associated these identifiers with the exact product and version this + service reported. That is an association, not a test result. +

+
    + {finding.cve_references.map((reference) => ( +
  • + {reference.cve_id} + + {reference.source} · {humanize(reference.match_type)} + +
  • + ))} +
+
+ )} + +
+

Evidence

+ {finding.evidence.length ? ( +
    + {finding.evidence.map((item) => ( +
  • + + {item.summary} + + Observation #{item.observation_id} + {item.discovery_run_id ? ` · discovery run #${item.discovery_run_id}` : ""} + {item.detection_run_id ? ` · detection run #${item.detection_run_id}` : ""} + + {item.sha256 && ( + + + {item.evidence_path} · {item.sha256.slice(0, 16)}… + + + )} + +
  • + ))} +
+ ) : ( +

None recorded.

+ )} +
+ +
+

Decision

+
+ {DECISIONS.map((decision) => ( + + ))} +
+

+ A finding is never deleted. RedDock resolves one that a later run no longer reproduces; + suppressing or accepting one records your decision and keeps its history. +

+
+ + ); +} + +export function DetectionPanel({ + dockyardId, + detectors, + runs, + observationCount, + onRan, + onError, +}: { + dockyardId: number; + detectors: Detector[]; + runs: DetectionRun[]; + observationCount: number; + onRan: () => Promise; + onError: (message: string | null) => void; +}) { + const [busy, setBusy] = useState(false); + + async function run() { + setBusy(true); + try { + await api.startDetection(dockyardId); + onError(null); + await onRan(); + } catch (error) { + onError(error instanceof Error ? error.message : "Could not run detection."); + } finally { + setBusy(false); + } + } + + return ( + <> +
+
+

DETECTION

+

Run detection

+

+ Detection reads what this Dockyard already recorded and contacts nothing. It takes no + target and no options, so every registered detector runs over the same stored state + and each finding it produces names the observations behind it. +

+
+ +
+ {observationCount === 0 && ( +

+ There is nothing to detect against yet. Run a scoped discovery first. +

+ )} +
+
+

REGISTERED DETECTORS

+

{detectors.length}

+
    + {detectors.map((detector) => ( +
  • + + {detector.title} + + {detector.id} {detector.version} + + {detector.description} + +
  • + ))} +
+
+
+ +
+
+
+

AUDIT TRAIL

+

Detection runs

+
+
+ {runs.length ? ( + + {runs.map((detection) => ( + + #{detection.id} + + + + + {(detection.detectors ?? []).map((entry) => ( +
+ {entry.id} {entry.status === "failed" ? "failed" : ""} + {entry.error &&
{entry.error}
} +
+ ))} + + + {detection.asset_count} assets · {detection.observation_count} observations + + + {detection.finding_count} produced · {detection.new_finding_count} new ·{" "} + {detection.resolved_finding_count} resolved + + + {detection.result_sha256 ? ( + {detection.result_sha256.slice(0, 16)}… + ) : ( + "—" + )} + + {formatDate(detection.completed_at ?? detection.created_at)} + + ))} +
+ ) : ( + + )} +
+ + ); +} diff --git a/frontend/src/Workspace.tsx b/frontend/src/Workspace.tsx index 304e963..844cf78 100644 --- a/frontend/src/Workspace.tsx +++ b/frontend/src/Workspace.tsx @@ -1,19 +1,32 @@ import { FormEvent, useCallback, useEffect, useState } from "react"; import { api } from "./api"; import { DataTable, DecisionPanel, EmptyState, StatusPill } from "./components"; +import { DetectionPanel, FindingsPanel } from "./Findings"; import { formatDate, humanize, kindLabel, plural } from "./format"; import type { Adapter, Asset, + DetectionRun, + Detector, DiscoveryRun, Dockyard, + Finding, Observation, ScopeEntry, ScopeEvaluation, ServiceRow, } from "./types"; -const tabs = ["Scope", "Discovery", "Assets", "Services", "Observations", "Runs"] as const; +const tabs = [ + "Scope", + "Discovery", + "Assets", + "Services", + "Observations", + "Detection", + "Findings", + "Runs", +] as const; type Tab = (typeof tabs)[number]; const ACTIVE = new Set(["pending", "running"]); @@ -21,11 +34,13 @@ const ACTIVE = new Set(["pending", "running"]); export function Workspace({ dockyard, adapters, + detectors, onBack, onError, }: { dockyard: Dockyard; adapters: Adapter[]; + detectors: Detector[]; onBack: () => void; onError: (message: string | null) => void; }) { @@ -35,21 +50,39 @@ export function Workspace({ const [services, setServices] = useState([]); const [observations, setObservations] = useState([]); const [runs, setRuns] = useState([]); + const [detections, setDetections] = useState([]); + const [findings, setFindings] = useState([]); + // Detection completes inside its request, so the findings view reloads on a + // counter rather than by polling. + const [detected, setDetected] = useState(0); const refresh = useCallback(async () => { try { - const [nextScope, nextAssets, nextServices, nextObservations, nextRuns] = await Promise.all([ + const [ + nextScope, + nextAssets, + nextServices, + nextObservations, + nextRuns, + nextDetections, + nextFindings, + ] = await Promise.all([ api.scope(dockyard.id), api.assets(dockyard.id), api.services(dockyard.id), api.observations(dockyard.id), api.discoveries(dockyard.id), + api.detections(dockyard.id), + api.findings(dockyard.id), ]); setScope(nextScope); setAssets(nextAssets); setServices(nextServices); setObservations(nextObservations); setRuns(nextRuns); + setDetections(nextDetections); + setFindings(nextFindings); + setDetected((current) => current + 1); onError(null); } catch (error) { onError(error instanceof Error ? error.message : "Could not load the Dockyard workspace."); @@ -82,6 +115,7 @@ export function Workspace({ {plural(assets.length, "asset")} {plural(services.length, "service")} {plural(runs.length, "discovery run")} + {plural(findings.length, "finding")}
@@ -114,6 +148,19 @@ export function Workspace({ onError={onError} /> )} + {tab === "Detection" && ( + + )} + {tab === "Findings" && ( + + )} {tab === "Assets" && } {tab === "Services" && } {tab === "Observations" && } @@ -208,7 +255,7 @@ function ScopePanel({

- Phase 1 accepts an IPv4 or IPv6 address, a network no larger than 256 addresses, an exact + A scope entry is an IPv4 or IPv6 address, a network no larger than 256 addresses, an exact hostname or an HTTP origin. Hostnames match exactly; there is no wildcard expansion.

@@ -482,8 +529,8 @@ function ObservationList({ observations }: { observations: Observation[] }) { return (

- An observation records what an adapter saw. It is not a finding and carries no severity; - interpretation arrives in a later phase. + An observation records what an adapter saw. It carries no severity and no verdict: a + detector turns observations into findings, and this record stays what it was.

{observations.map((observation) => ( diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 0a024eb..5166f0f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,9 +1,13 @@ import type { Adapter, Asset, + DetectionRun, + Detector, Dockyard, DiscoveryRun, EvidenceRecord, + Finding, + FindingDetail, Health, Observation, ScopeEntry, @@ -40,6 +44,16 @@ function post(path: string, body: unknown): Promise { return request(path, { method: "POST", body: JSON.stringify(body) }); } +/** Only the filters the API accepts, and only when they are set. */ +function query(filters: Record): string { + const parameters = new URLSearchParams(); + for (const [key, value] of Object.entries(filters)) { + if (value) parameters.set(key, value); + } + const rendered = parameters.toString(); + return rendered ? `?${rendered}` : ""; +} + /** A discovery request that DockGuard denied is a result, not a transport error. */ export type DiscoveryOutcome = | { accepted: boolean; run: DiscoveryRun } @@ -68,6 +82,21 @@ export const api = { observations: (id: number) => request(`/dockyards/${id}/observations`), evidence: (id: number) => request(`/dockyards/${id}/evidence`), + detectors: () => request("/detectors"), + detections: (id: number) => request(`/dockyards/${id}/detections`), + /** Detection takes no target and no options, so the request carries nothing. */ + startDetection: (id: number) => post(`/dockyards/${id}/detections`, {}), + + findings: (id: number, filters: { severity?: string; status?: string } = {}) => + request(`/dockyards/${id}/findings${query(filters)}`), + finding: (id: number, findingId: number) => + request(`/dockyards/${id}/findings/${findingId}`), + updateFinding: (id: number, findingId: number, status: string, note?: string) => + request(`/dockyards/${id}/findings/${findingId}`, { + method: "PATCH", + body: JSON.stringify({ status, note: note ?? null }), + }), + discoveries: (id: number) => request(`/dockyards/${id}/discoveries`), discovery: (id: number, runId: number) => request(`/dockyards/${id}/discoveries/${runId}`), diff --git a/frontend/src/components.tsx b/frontend/src/components.tsx index 30751bd..227e374 100644 --- a/frontend/src/components.tsx +++ b/frontend/src/components.tsx @@ -45,9 +45,11 @@ export function Metric({ ); } +const OK_STATUSES = new Set(["completed", "resolved"]); +const STOP_STATUSES = new Set(["denied", "failed", "open"]); + export function StatusPill({ status }: { status: string }) { - const tone = - status === "completed" ? "ok" : status === "denied" || status === "failed" ? "stop" : "busy"; + const tone = OK_STATUSES.has(status) ? "ok" : STOP_STATUSES.has(status) ? "stop" : "busy"; return {humanize(status)}; } diff --git a/frontend/src/format.ts b/frontend/src/format.ts index ee5b1ae..f0c0d0f 100644 --- a/frontend/src/format.ts +++ b/frontend/src/format.ts @@ -5,6 +5,17 @@ export function formatDate(value: string | null) { ); } +/** A shorter form for dense tables, where the year rarely earns its width. */ +export function formatCompact(value: string | null) { + if (!value) return "—"; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + export function formatBytes(bytes: number) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 0f83d31..84476e5 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2,6 +2,7 @@ * { box-sizing: border-box; } body { margin: 0; min-width: 320px; background: #0d1117; } button, input, textarea { font: inherit; } button { cursor: pointer; } .app-shell { min-height: 100vh; display: grid; grid-template-columns: 244px minmax(0, 1fr); } .sidebar { background: #0b1016; border-right: 1px solid #30363d; display: flex; flex-direction: column; padding: 26px 14px 18px; } .brand { display: flex; align-items: center; gap: 10px; padding: 0 10px; font-size: 22px; font-weight: 750; letter-spacing: -.5px; }.brand-mark { display: grid; place-items: center; background: #d7263d; width: 31px; height: 31px; border-radius: 7px; color: white; font-size: 16px; box-shadow: 0 0 0 4px rgba(215,38,61,.12); }.tagline { color: #8b949e; font-size: 12px; margin: 12px 10px 32px; }.sidebar nav { display: grid; gap: 4px; }.nav-item { color: #8b949e; border: 0; background: transparent; text-align: left; padding: 10px 11px; border-radius: 6px; position: relative; font-size: 14px; }.nav-item:hover { color: #f0f3f6; background: #161b22; }.nav-item.active { color: #fff; background: #251419; box-shadow: inset 3px 0 #d7263d; }.planned-dot { width: 5px; height: 5px; border-radius: 50%; background: #8b949e; display: inline-block; margin-left: 8px; vertical-align: middle; }.sidebar-footer { margin-top: auto; border-top: 1px solid #30363d; padding: 18px 10px 0; color: #8b949e; font-size: 12px; }.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #d29922; margin-right: 7px; }.status-dot.online { background: #3fb950; box-shadow: 0 0 9px #3fb950; } main { padding: 42px clamp(24px, 5vw, 76px); max-width: 1500px; width: 100%; margin: 0 auto; } header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 32px; } h1, h2, p { margin-top: 0; } h1 { font-size: clamp(30px, 3vw, 38px); letter-spacing: -.9px; margin-bottom: 0; } h2 { letter-spacing: -.4px; margin-bottom: 8px; }.eyebrow { color: #8b949e; font-size: 11px; letter-spacing: .1em; font-weight: 700; margin-bottom: 8px; }.phase-pill, .draft-pill, .count-chip { border: 1px solid #3a3031; color: #ffb7be; background: #251419; border-radius: 999px; padding: 5px 9px; font-weight: 700; font-size: 10px; letter-spacing: .07em; }.alert { padding: 13px 15px; border: 1px solid #7a1020; color: #ffb7be; background: #251419; border-radius: 7px; margin-bottom: 22px; }.hero { border: 1px solid #3b3032; border-radius: 10px; background: linear-gradient(120deg, #161b22, #21161a); padding: 34px; display: flex; justify-content: space-between; gap: 32px; align-items: center; margin-bottom: 20px; }.hero h2 { font-size: 27px; max-width: 640px; }.hero p:not(.eyebrow), .planned-state > p:last-child { color: #8b949e; margin-bottom: 0; max-width: 690px; line-height: 1.55; }.primary-button { border: 1px solid #ff5664; border-radius: 7px; padding: 10px 14px; color: #fff; font-weight: 650; background: #d7263d; white-space: nowrap; }.primary-button:hover { background: #ff3b4d; }.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 16px; margin-bottom: 20px; }.metric, .panel { background: #161b22; border: 1px solid #30363d; border-radius: 9px; }.metric { padding: 21px; min-height: 132px; }.metric p { text-transform: uppercase; color: #8b949e; letter-spacing: .07em; font-size: 11px; font-weight: 700; margin-bottom: 13px; }.metric strong { font-size: 27px; }.metric small { display: block; color: #8b949e; margin-top: 8px; font-size: 11px; }.tone-success { color: #3fb950; }.panel { padding: 25px; }.section-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 22px; }.section-heading h2 { margin-bottom: 0; }.text-button { color: #ff9ba5; border: 0; background: transparent; font-weight: 650; }.dockyard-list { display: grid; }.dockyard-row { border: 0; border-top: 1px solid #30363d; background: transparent; color: #f0f3f6; display: flex; text-align: left; align-items: center; gap: 13px; padding: 14px 5px; }.dockyard-row:hover { background: #1c222b; }.row-icon { border-radius: 6px; background: #251419; border: 1px solid #51232a; width: 30px; height: 30px; display: grid; place-items: center; color: #ff9ba5; font-size: 12px; font-weight: 800; }.row-main { min-width: 0; display: grid; gap: 4px; }.row-main strong { font-size: 14px; }.row-main small, .row-meta small { color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.row-meta { margin-left: auto; display: grid; gap: 7px; justify-items: end; font-size: 11px; }.draft-pill { text-transform: uppercase; padding: 3px 6px; font-size: 9px; }.empty-state { color: #8b949e; text-align: center; padding: 42px 20px; border-top: 1px solid #30363d; }.empty-state span, .planned-state > span { color: #d7263d; font-size: 28px; }.empty-state p { margin: 10px auto 0; max-width: 450px; line-height: 1.5; }.split-layout { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(300px, .7fr); gap: 20px; }.dockyard-form { display: grid; gap: 13px; padding-bottom: 24px; }.dockyard-form label { display: grid; gap: 7px; color: #c9d1d9; font-size: 13px; font-weight: 650; }.dockyard-form label span { color: #8b949e; font-weight: 400; }.dockyard-form input, .dockyard-form textarea { color: #f0f3f6; border: 1px solid #30363d; border-radius: 6px; background: #0d1117; padding: 10px; outline: none; resize: vertical; }.dockyard-form input:focus, .dockyard-form textarea:focus { border-color: #d7263d; box-shadow: 0 0 0 3px rgba(215,38,61,.15); }.dockyard-form .primary-button { justify-self: start; }.list-wrap { border-top: 1px solid #30363d; }.detail-panel { align-self: start; min-height: 310px; }.detail-copy { color: #c9d1d9; line-height: 1.5; margin: 18px 0 28px; }.detail-panel dl { margin: 0; display: grid; gap: 14px; }.detail-panel dl div { border-top: 1px solid #30363d; padding-top: 12px; }.detail-panel dt { color: #8b949e; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; margin-bottom: 5px; }.detail-panel dd { margin: 0; font-size: 13px; color: #c9d1d9; }.planned-state { margin-top: 70px; max-width: 650px; padding: 40px; border: 1px dashed #3b4654; border-radius: 10px; background: #161b22; }.planned-state h2 { font-size: 28px; margin-top: 15px; } +.severity { border-radius: 999px; padding: 3px 9px; font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; border: 1px solid #3b4654; color: #c9d1d9; background: #1c222b; white-space: nowrap; }.severity.critical { border-color: #b3243a; color: #ffd3d8; background: #2e141a; }.severity.high { border-color: #7a1020; color: #ffb7be; background: #251419; }.severity.medium { border-color: #5c4813; color: #e3b341; background: #241d0d; }.severity.low { border-color: #3b4654; color: #c9d1d9; background: #1c222b; }.severity.informational { border-color: #30363d; color: #8b949e; background: #161b22; }.tag-row { display: flex; gap: 8px; flex-wrap: wrap; margin: 0 0 18px; }.filter-row { display: flex; gap: 14px; flex-wrap: wrap; margin: 18px 0 6px; }.filter-row label { display: grid; gap: 6px; color: #8b949e; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }.filter-row select { color: #f0f3f6; border: 1px solid #30363d; border-radius: 6px; background: #0d1117; padding: 8px 10px; font-size: 13px; text-transform: none; letter-spacing: 0; font-weight: 400; min-width: 160px; }.findings-layout { grid-template-columns: minmax(0, 1.9fr) minmax(320px, .8fr); }.findings-layout .data-table th, .findings-layout .data-table td { padding-right: 10px; }.seen-cell { white-space: nowrap; }.seen-cell small { display: block; color: #8b949e; font-size: 11px; margin-top: 3px; }.finding-row { cursor: pointer; }.finding-row:hover td { background: #1c222b; }.finding-row.selected td { background: #251419; }.link-button { border: 0; background: transparent; color: #f0f3f6; font-weight: 650; font-size: 13px; padding: 0; text-align: left; }.finding-row:hover .link-button { color: #ff9ba5; }.evidence-list li { align-items: flex-start; }.evidence-list .row-main { gap: 5px; white-space: normal; }.evidence-list small { white-space: normal; overflow: visible; }.detection-actions { margin-top: 18px; }.detection-runs { margin-top: 20px; }.detail-panel .scope-list li { padding: 12px 0; }.detail-panel .scope-list .row-main small { white-space: normal; overflow: visible; } @media (max-width: 800px) { .app-shell { grid-template-columns: 1fr; }.sidebar { padding: 16px; border-right: 0; border-bottom: 1px solid #30363d; }.tagline, .sidebar-footer { display: none; }.sidebar nav { display: flex; overflow-x: auto; margin-top: 15px; }.nav-item { white-space: nowrap; }.sidebar .brand { padding: 0; } main { padding: 28px 20px; } .metrics, .split-layout { grid-template-columns: 1fr; }.hero { align-items: flex-start; flex-direction: column; }.row-meta small { display: none; } } .dockyard-form select { color: #f0f3f6; border: 1px solid #30363d; border-radius: 6px; background: #0d1117; padding: 10px; outline: none; }.dockyard-form select:focus { border-color: #d7263d; box-shadow: 0 0 0 3px rgba(215,38,61,.15); }.inline-form { grid-template-columns: minmax(0, 1fr) 150px auto; align-items: end; gap: 12px; }.inline-form .primary-button { justify-self: stretch; }.button-row { display: flex; gap: 10px; flex-wrap: wrap; }.secondary-button { border: 1px solid #3b4654; border-radius: 7px; padding: 10px 14px; color: #f0f3f6; font-weight: 650; background: #1c222b; }.secondary-button:hover { background: #252c37; }.primary-button:disabled { opacity: .45; cursor: not-allowed; }.hint { color: #8b949e; font-size: 12px; line-height: 1.5; margin: 12px 0 0; } .workspace-header { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; margin-bottom: 16px; }.workspace-header h2 { margin: 12px 0 6px; }.workspace-header .text-button { padding: 0; }.workspace-counts { display: grid; gap: 8px; text-align: right; color: #8b949e; font-size: 12px; white-space: nowrap; }.workspace-counts strong { color: #f0f3f6; font-size: 15px; margin-right: 5px; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8070920..0b903d2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -115,3 +115,101 @@ export type Adapter = { profiles: AdapterProfile[]; target_kinds: string[]; }; + +export type Detector = { + id: string; + version: string; + title: string; + description: string; + consumes: string[]; +}; + +export type DetectorOutcome = { + id: string; + version: string; + status: string; + findings: number; + error: string | null; +}; + +export type DetectionRun = { + id: number; + dockyard_id: number; + status: string; + detectors: DetectorOutcome[] | null; + enrichment: { + id: string; + version: string | null; + available: boolean; + warning: string | null; + } | null; + asset_count: number; + service_count: number; + observation_count: number; + finding_count: number; + new_finding_count: number; + resolved_finding_count: number; + error: string | null; + evidence_path: string | null; + metadata_sha256: string | null; + result_sha256: string | null; + created_at: string; + started_at: string | null; + completed_at: string | null; +}; + +/** Enrichment, not proof: a catalogue matched a reported product and version. */ +export type CveReference = { + cve_id: string; + source: string; + source_version: string | null; + match_type: string; + matched_product: string; + matched_version: string; + url: string | null; +}; + +export type Finding = { + id: number; + fingerprint: string; + detector: string; + detector_version: string; + rule_id: string; + title: string; + category: string; + severity: string; + confidence: string; + status: string; + status_note: string | null; + asset_id: number | null; + service_id: number | null; + first_seen: string; + last_seen: string; + resolved_at: string | null; + first_detection_run_id: number | null; + last_detection_run_id: number | null; + cve_references: CveReference[]; + asset_label: string | null; + service_endpoint: string | null; + evidence_count: number; +}; + +/** One observation that supported a finding, with the hash that proves it. */ +export type FindingEvidence = { + id: number; + observation_id: number; + discovery_run_id: number | null; + detection_run_id: number | null; + evidence_record_id: number | null; + summary: string; + created_at: string; + evidence_path: string | null; + sha256: string | null; +}; + +export type FindingDetail = Finding & { + description: string; + remediation: string | null; + detail: Record | null; + evidence: FindingEvidence[]; +}; diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py index 2e240a6..460f45f 100644 --- a/scripts/smoke_test.py +++ b/scripts/smoke_test.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -"""Phase 1 end-to-end smoke test against a running RedDock container. +"""End-to-end smoke test against a running RedDock container. -It exercises the whole discovery story — Dockyard, authorized scope, DockGuard -decision, adapter, asset, service, observation, evidence — against loopback -only. It never contacts a system outside the machine running RedDock. +It exercises the whole story — Dockyard, authorized scope, DockGuard decision, +adapter, asset, service, observation, evidence, detection, finding — against +loopback only. It never contacts a system outside the machine running RedDock, +and the only HTTP origin it probes is RedDock's own. Usage: python scripts/smoke_test.py [base-url] """ @@ -39,7 +40,7 @@ def check(label: str, condition: bool, detail: object = "") -> None: def main(base: str) -> None: - print(f"RedDock Phase 1 smoke test against {base}\n") + print(f"RedDock smoke test against {base}\n") status, health = call(base, "GET", "/api/health") check("health endpoint responds", status == 200 and health["status"] == "healthy") @@ -126,7 +127,96 @@ def main(base: str) -> None: _, repeated = call(base, "GET", f"/api/dockyards/{dockyard_id}/assets") check("repeat discovery did not duplicate assets", len(repeated) == len(assets)) - print("\nPhase 1 smoke test passed.") + print("\nPhase 1 discovery verified.\n") + detection_checks(base, dockyard_id) + print("\nSmoke test passed.") + + +def wait_for_runs(base: str, dockyard_id: int) -> None: + deadline = time.monotonic() + RUN_DEADLINE + while time.monotonic() < deadline: + _, runs = call(base, "GET", f"/api/dockyards/{dockyard_id}/discoveries") + if all(item["status"] not in ("pending", "running") for item in runs): + return + time.sleep(2) + + +def detection_checks(base: str, dockyard_id: int) -> None: + """Phase 2: observations become findings, and only through a detector.""" + # RedDock probes its own origin. Nothing outside this container is contacted. + status, entry = call( + base, "POST", f"/api/dockyards/{dockyard_id}/scope", {"target": "http://127.0.0.1:8080"} + ) + check("own origin authorized", status == 201, entry["value"]) + + status, probe = call( + base, + "POST", + f"/api/dockyards/{dockyard_id}/discoveries", + {"target": "http://127.0.0.1:8080", "adapter": "http", "profile": "http_probe"}, + ) + check("http probe accepted", status == 202, f"run={probe['id']}") + wait_for_runs(base, dockyard_id) + + status, detectors = call(base, "GET", "/api/detectors") + check("detectors advertised", status == 200 and len(detectors) >= 1, len(detectors)) + + status, run = call(base, "POST", f"/api/dockyards/{dockyard_id}/detections", {}) + check("detection run completed", status == 201 and run["status"] == "completed", run["status"]) + check( + "every detector ran", + all(entry["status"] == "completed" for entry in run["detectors"]), + [entry["id"] for entry in run["detectors"]], + ) + check( + "detection retained hashed evidence", + bool(run["result_sha256"]) and len(run["result_sha256"]) == 64, + run["evidence_path"], + ) + check( + "cve enrichment is off by default", + run["enrichment"]["available"] is False, + run["enrichment"]["id"], + ) + + _, findings = call(base, "GET", f"/api/dockyards/{dockyard_id}/findings") + check("findings produced", len(findings) >= 1, len(findings)) + rules = {finding["rule_id"] for finding in findings} + check("plaintext http detected on the probed origin", "plaintext-http" in rules, sorted(rules)) + check( + "severity and confidence are separate", + all(finding["severity"] and finding["confidence"] for finding in findings), + f"{findings[0]['severity']}/{findings[0]['confidence']}", + ) + + _, detail = call(base, "GET", f"/api/dockyards/{dockyard_id}/findings/{findings[0]['id']}") + check("finding names its detector", bool(detail["detector"]), detail["detector"]) + check( + "finding is traceable to hashed evidence", + bool(detail["evidence"]) and len(detail["evidence"][0]["sha256"] or "") == 64, + detail["evidence"][0]["summary"], + ) + + _, observations = call(base, "GET", f"/api/dockyards/{dockyard_id}/observations") + check( + "observations still carry no verdict", + all("severity" not in item for item in observations), + f"{len(observations)} observations", + ) + + # Running detection again must reconcile, not duplicate. + _, again = call(base, "POST", f"/api/dockyards/{dockyard_id}/detections", {}) + _, repeated = call(base, "GET", f"/api/dockyards/{dockyard_id}/findings") + check( + "repeat detection did not duplicate findings", + len(repeated) == len(findings) and again["new_finding_count"] == 0, + f"{again['finding_count']} produced, {again['new_finding_count']} new", + ) + + status, refused = call( + base, "POST", f"/api/dockyards/{dockyard_id}/detections", {"target": "10.0.0.5"} + ) + check("detection accepts no operator parameters", status == 422, refused["detail"][0]["msg"]) if __name__ == "__main__":