Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ include =
*/longrunning/*
*/specialists/*
*/sentinel_harness/*
*/intake/*
*/mockdata/*
omit =
*/site-packages/*
*/.venv/*
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<img alt="python" src="https://img.shields.io/badge/python-3.10%2B-2997ff"/>
<img alt="bedrock-agentcore" src="https://img.shields.io/badge/Amazon%20Bedrock-AgentCore%20Harness-ff9900"/>
<img alt="version" src="https://img.shields.io/badge/version-0.5.1-2997ff"/>
<img alt="tests" src="https://img.shields.io/badge/offline%20tests-4073%20passing-1D8102"/>
<img alt="tests" src="https://img.shields.io/badge/offline%20tests-4219%20passing-1D8102"/>
<img alt="coverage" src="https://img.shields.io/badge/coverage-92%25-1D8102"/>
<img alt="milestones" src="https://img.shields.io/badge/milestones-M0--M15%20delivered-1D8102"/>
<img alt="hardening" src="https://img.shields.io/badge/adversarial%20audit-100%20defects%20fixed-8b5cf6"/>
Expand Down Expand Up @@ -99,7 +99,7 @@ Honest build status per capability — mirrors the self-audit.
| **Tools** | `nvd_lookup` / `epss_kev` / `attack_lookup` / `web_search` | 🟡 **reference stubs** (offline-safe, contract-tested) | `tools/`, `tests/test_tool_handlers.py` |
| **Tools** | `siem_query` / `asset_lookup` / `enrich_ioc` / `ops_query` — backend-pluggable | 🟢 **built + tested** (offline mock default; `*_LIVE`=1 switches to a real stdlib-HTTP client — env-driven URL + bearer, timeouts, all failures→`upstream_error` with no silent fallback — proven end-to-end against an in-process 127.0.0.1 mock server, zero external network) | `tools/{siem_query,asset_lookup,enrich_ioc,ops_query}/`, `tests/test_*_live.py` |

🟢 built & validated · 🟡 built, partial · 🟠 designed with loadable config · ⚪ design narrative only. **4073 offline tests pass** (+8 skipped when optional deps are absent or opt-in checks are off).
🟢 built & validated · 🟡 built, partial · 🟠 designed with loadable config · ⚪ design narrative only. **4219 offline tests pass** (+42 skipped when optional deps are absent or opt-in build/live checks are off).

## 🚀 Quickstart

Expand Down Expand Up @@ -247,7 +247,7 @@ Borrowed patterns (see [`docs/BLUEPRINT.md`](docs/BLUEPRINT.md)): supervisor→s
| [`docs/GOVERNANCE.md`](docs/GOVERNANCE.md) | Registry dual-gate, HITL, sandbox hooks, and tag-guard controls |
| [`docs/COMPLIANCE.md`](docs/COMPLIANCE.md) | Capability → SOC 2 / ISO 27001 / NIST CSF 2.0 control mapping (anchors machine-verified) |
| [`docs/OBSERVABILITY.md`](docs/OBSERVABILITY.md) | Logging (`logutil`), metrics (token/latency/tool-call/error/eval), the OTEL/Transaction-Search path |
| [`docs/TESTING.md`](docs/TESTING.md) | The 4073-test offline suite: layout, determinism, how to run |
| [`docs/TESTING.md`](docs/TESTING.md) | The 4219-test offline suite: layout, determinism, how to run |
| [`docs/FIDELITY-REPORT.md`](docs/FIDELITY-REPORT.md) | The self-audit — real vs. built vs. designed, with limits stated |
| [`docs/ROADMAP.md`](docs/ROADMAP.md) | Delivered milestones (M0–M12) and what's next |
| [**API reference (live)**](https://aws-samples.github.io/sample-sentinel-harness/) | Rendered `sentinel_harness` API docs (pdoc → GitHub Pages) |
Expand Down Expand Up @@ -285,7 +285,7 @@ sentinel-harness/
├── iac-cdk/ L3 CDK stacks (9; guardrail/identity/obs/vpc live) 🟢
├── iac-terraform/ deployable Terraform mirror (validate-clean) 🟢
├── docs/ QUICKSTART · ARCHITECTURE · BLUEPRINT · SETUP · HARNESSES · GOVERNANCE · TESTING · FIDELITY-REPORT · ROADMAP
├── tests/ offline unit + config tests (4073) 🟢
├── tests/ offline unit + config tests (4219) 🟢
└── .github/workflows/ CI incl. a customer-name / secret gate
```

Expand Down
2 changes: 1 addition & 1 deletion docs/COMPARISON.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ is partial, it is marked 🟡 here exactly as it is there.
| **Code you own/maintain** | Least — agents are YAML; the heavy lifting is the AWS-managed loop + a tested library | Moderate — the loop is yours | Moderate–high — graph topology + hosting | Most — everything |
| **Security-ops opinionation** | High — three-layer SecOps blueprint, Play Mode, BAS detection-replay, sandbox hooks, egress control ship in the box | None (general-purpose) | None (general-purpose) | None |

Numbers above (4073 tests, 23 scenarios, 38 evidence artifacts, 20 tools, 9 skills,
Numbers above (4219 tests, 23 scenarios, 38 evidence artifacts, 20 tools, 9 skills,
8 harnesses, 9 CDK stacks) match the README and are all offline/deterministic unless a
row says **live-validated**; live claims were validated on a **non-production dev/test
account** with every account id scrubbed to `000000000000`.
Expand Down
2 changes: 1 addition & 1 deletion docs/FIDELITY-REPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ This is real, not narration:
- **Layer 3 foundation ships runnable, tested code.** The dual-gate tool/skill registry, PreToolUse sandbox hook, and Agent Factory (fleet provision, dry-run, cross-env tag-guard) are built and unit-tested.
- **Native IaC, not hand-rolled resources.** The gateway / registry / memory / harness stacks use the native `AWS::BedrockAgentCore::*` CloudFormation types. Per the README status matrix, the **Gateway and Memory CFN types are registered**; the Registry type is not yet in CFN (see limitations).
- **Config path works.** `pip install -e .` succeeds (`[tool.setuptools] packages = ["sentinel_harness", "intake"]`), the `sentinel` console script works, and `sentinel create <harness.yaml>` loads real config via `sentinel_harness/loader.py` (systemPrompt file read, `bedrockModelConfig` / `agentCoreGateway` / `managedMemoryConfiguration` mapping, `${ENV}` expansion, `@gateway/tool` allowedTools grammar).
- **Scale.** 4073 offline tests pass (+8 skipped when optional deps are absent or opt-in checks are off) across 175 test files, with 38 evidence JSON artifacts, 23 scenarios, 20 tools (incl. a 7-tool deterministic detection-engineering suite), an `iac-cdk` project (9 stacks synth-green) and an `iac-terraform` mirror (`validate`-clean).
- **Scale.** 4219 offline tests pass (+42 skipped when optional deps are absent or opt-in build/live checks are off) across 186 test files, with 38 evidence JSON artifacts, 23 scenarios, 20 tools (incl. a 7-tool deterministic detection-engineering suite), an `iac-cdk` project (9 stacks synth-green) and an `iac-terraform` mirror (`validate`-clean).
- **Clean anonymization.** No real account IDs (only the `000000000000` placeholder), no customer or company names, no secrets. The CI secret-and-name scan is self-non-matching and fails the build on any hit.

## 4. Live controls retained for demos (us-east-1)
Expand Down
10 changes: 10 additions & 0 deletions docs/INVARIANTS.md

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ live) · 🟡 skeleton / partial · 🔴 gap.
| `specialists/` | `cve-intel` (docker-build + live-validated on AgentCore Runtime) + `attack-mapper` / `threat-hunt` (real graph/plan builders) + `adversarial-reviewer` (agent_a2a + local_a2a + two-stage Dockerfile + contract test) | ✅ | all four specialists shipped |
| `longrunning/` | `bas-runner` (BAS case-gen + detection-replay) + `detonation` (full simulated microVM lifecycle + orchestrator) | 🟩 | both built + tested; detonation stays an honest SIMULATED no-op |
| `iac-cdk/lib/` | 9 synth-green stacks — `gateway` / `registry` / `memory` / `network` / `identity` / `guardrail` / `observability` / `harness` / `runtime` (+ `iam`); `iac-terraform/` mirror is `terraform validate`-clean | ✅ | `guardrail` / `identity` / `observability` LIVE-deployed (us-east-1); the Registry + `runtime` custom-resource/raw-CfnResource stacks synth clean but fail on deploy until their CFN types are GA (both control-plane APIs are separately live-verified — Registry via `registry_live.py`, `CreateAgentRuntime` via a real arm64 microVM that served a live A2A call, HTTP 200, real Bedrock model, on a non-prod test account, then torn down — `evidence/live_a2a_runtime_result.json`) |
| `tests/` | 175 files, **4073 offline passing** (+8 skipped) | ✅ | add tests with each new module |
| `tests/` | 186 files, **4219 offline passing** (+42 skipped) | ✅ | add tests with each new module |
| `evidence/` | 37 evidence sets | ✅ | add one per milestone |

### 0.3 Fit score (vs. a full three-layer SecOps agent program)
Expand Down Expand Up @@ -181,8 +181,8 @@ Each milestone gives: **goal / files / reused APIs / acceptance (live evidence)
Suggest one feature branch per milestone.

### M0 — Environment & baseline reproduction (half a day)
**Goal:** on a fresh machine, get all 4073 offline tests green and reproduce ≥1 live scenario.
- [ ] `uv sync` + `uv run pytest -q` → 4073 passing (+8 skipped) (offline).
**Goal:** on a fresh machine, get all 4219 offline tests green and reproduce ≥1 live scenario.
- [ ] `uv sync` + `uv run pytest -q` → 4219 passing (+42 skipped) (offline).
- [ ] Configure `SENTINEL_EXECUTION_ROLE_ARN` / `SENTINEL_REGION` / `AWS_PROFILE` (non-prod) — see `docs/SETUP.md`.
- [ ] Run `scenarios/scenario_cve_triage.py`; compare `evidence/cve_triage_result.json` shape.
- [ ] Run `scenarios/scenario_hitl_resume.py`; reproduce pause→approve→resume.
Expand Down Expand Up @@ -419,7 +419,7 @@ hand-off reuses the live-capable M1/M2 engine (driven offline here, labeled a wi
(`make deploy`, cost note, `make destroy`) + the no-lock-in export. — `docs/QUICKSTART.md`
- [x] `tests/smoke/`: offline acceptance suite (default offline; `SENTINEL_SMOKE_LIVE=1` opt-in for live). — `tests/smoke/`

**Acceptance:** `make test` → 4073 offline tests green; `make seed-registry` → dual-gate `ok`;
**Acceptance:** `make test` → 4219 offline tests green; `make seed-registry` → dual-gate `ok`;
`make create-harnesses` (DRY_RUN=1) → 8 harnesses validate offline with zero AWS; `sentinel export` → valid
compilable Strands Python; `make smoke` → the offline acceptance suite green. A fresh non-prod account can then
run `make deploy` (free-tier foundation) and the live scenarios; `make destroy` tears it all down.
Expand Down Expand Up @@ -627,7 +627,7 @@ if eval.score >= criteria:
---

## 6. Testing & acceptance charter
- **offline**: every new module gets `tests/test_*.py` (mock AWS); keep `uv run pytest -q` green (now 4073, +6 skipped, only grows).
- **offline**: every new module gets `tests/test_*.py` (mock AWS); keep `uv run pytest -q` green (now 4219, +6 skipped, only grows).
- **config parity**: every new `harness.yaml` must pass `factory.provision_fleet(dry_run=True)` + `test_config_validation.py`.
- **live evidence**: each milestone runs one real call, drops `evidence/<milestone>_result.json` + `.log`.
- **governance**: each new tool keeps `registry.governance_check().ok == True`.
Expand Down Expand Up @@ -837,7 +837,7 @@ with a harness that ever carried an extra endpoint taking >5 min to clear versus
consistency, scoped to present-tense claims so ROADMAP changelog entries
("2126 → 2352") are not falsely flagged.

**Acceptance:** suite 2493 → **2590** offline passing (+8 skipped), ruff clean,
**Acceptance:** suite 2493 → **2590** offline passing (+42 skipped), ruff clean,
coverage 90% (gate 88), both mypy gates green, docs-drift + invariant-doc guards
green, and `evidence/m18_gates_live_result.json` `closed: true` with zero residue
on a real account.
Expand Down
12 changes: 12 additions & 0 deletions harnesses/research-supervisor/harness.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ allowedTools:
- "@gateway/nvd_lookup" # CVE / CVSS lookup
- "@gateway/epss_kev" # EPSS score + CISA KEV enrichment
- "@gateway/attack_lookup" # MITRE ATT&CK technique lookup
# NOT YET RESOLVABLE — `registry/tools.yaml` ships web_search as status='pending'
# (SecOps has not approved its egress allowlist), so `registry.resolve("web_search")`
# raises RegistryError and this grant yields NO tool at runtime. Verified:
# RegistryError: tool 'web_search' is registered but status='pending' (not approved)
#
# The grant is kept deliberately: it documents the capability this supervisor is
# intended to have, and `pending` is an on-purpose governance demo (see
# docs/GOVERNANCE.md). But `allowedTools` is a GRANT, not a lookup — an unresolvable
# name does not raise, the agent just comes up with a smaller tool surface than the
# config implies (INV-HARNESS-1). Without this comment an operator reads the file and
# believes the supervisor can search the web. Flip the registry entry to `approved`
# once the egress allowlist is signed off, and delete these lines.
- "@gateway/web_search" # egress-controlled web search (text, no download)

memory:
Expand Down
14 changes: 14 additions & 0 deletions iac-terraform/identity.tf
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,20 @@ resource "aws_cognito_user_pool_client" "human" {

supported_identity_providers = ["COGNITO"]

# Account-enumeration defence. MUST be set explicitly: the AWS default is LEGACY, under which
# Cognito returns a DIFFERENT error for "user does not exist" than for "wrong password", so an
# attacker can enumerate valid usernames by reading the error alone.
#
# `iac-cdk/lib/identity-stack.ts` sets `preventUserExistenceErrors: true` on its human client and
# this mirror did not, so the Terraform path deployed a WEAKER pool than the CDK path while
# README called them a mirror. Neither `terraform validate` nor `cdk synth` can see that — the
# setting is valid in both states — which is why INV-IAC-5 tests it (same shape as INV-IAC-4,
# where the mirror claim was false for observability).
#
# Only the human client needs it: the machine client below uses client_credentials, which
# authenticates an app identity rather than a user, so it has no username surface to enumerate.
prevent_user_existence_errors = "ENABLED"

# The hosted UI / OAuth endpoints require the domain to exist first.
depends_on = [aws_cognito_user_pool_domain.this]
}
Expand Down
42 changes: 41 additions & 1 deletion sentinel_harness/egress.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,14 @@ def parse_ip_literal(host: str) -> Optional[ipaddress._BaseAddress]:
except ValueError:
pass
try:
if candidate.lower().startswith("0x"):
# NOTE the `"." not in candidate` guards. Without them the integer branches claim
# dotted spellings they cannot parse: `0xa9.0xfe.0xa9.0xfe` starts with "0x", so
# `int(candidate, 16)` was tried, raised ValueError, and the whole function returned
# None — meaning `assert_safe_url` treated a dotted-hex metadata address as a DNS name
# and ALLOWED it. The dotted-hex branch below (`octet.lower().startswith("0x")`) was
# therefore unreachable, which is how coverage surfaced the bug: two statements written
# for hex octets had never executed.
if candidate.lower().startswith("0x") and "." not in candidate:
value = int(candidate, 16)
elif candidate.isdigit():
# A leading zero means octal in this notation; a plain digit run is decimal.
Expand Down Expand Up @@ -128,6 +135,39 @@ def assert_safe_url(url: str) -> None:
ip = parse_ip_literal(host)
if ip is None:
return # a DNS name — resolution is the runtime egress policy's business
# `is_loopback` and `is_private` are load-bearing, not belt-and-braces.
#
# This check previously listed only link_local / multicast / reserved / unspecified, and the
# error message already claimed to refuse "non-routable" addresses. Measured, it did not:
#
# http://169.254.169.254/latest/meta-data/ BLOCKED (link-local, so it happened to be caught)
# http://127.0.0.1:8080/admin ALLOWED <-- loopback
# http://10.0.0.5/internal ALLOWED <-- private
# http://192.168.1.1/router ALLOWED <-- private
# http://100.64.0.1/x ALLOWED <-- CGNAT (is_private covers 100.64/10)
#
# So the metadata service was blocked while an agent could still reach a service on the
# runtime's own loopback or pivot into the VPC — the SSRF cases an egress guard exists for.
# IPv6 loopback `::1` was caught only incidentally, because CPython also reports it as
# `is_reserved`; relying on that coincidence for v6 while v4 fell through is exactly the kind
# of accidental coverage this repo records as indistinguishable from a real check.
# DELIBERATELY not `not ip.is_global`, and this scope was re-derived rather than assumed.
#
# Broadening the check to refuse every non-global address (loopback, RFC 1918, CGNAT) looked
# like a strict improvement and is NOT one. It fails 46 tests across 10 modules, and reading
# them showed why: `test_web_search_live.py` names `http://127.0.0.1:8080/search` a SAFE
# target, because an adopter points a `*_LIVE` tool at a stub or a sidecar on the runtime's own
# loopback. This module's docstring draws the same line — it defends against alternate IP
# spellings of the METADATA service and against redirects, and states that resolution-time
# concerns are "the runtime network policy's job". Loopback and VPC-internal egress is that
# policy's call, not this pre-flight check's.
#
# So 46 failures were evidence of a deliberate contract, not of 46 latent bugs. Recorded
# because the tempting move — "edit the tests, they assert something unsafe" — would have
# rewritten a design decision to match a guess.
#
# What WAS a genuine defect is the parse bug above: `0xa9.0xfe.0xa9.0xfe` is the metadata
# service, squarely inside this guard's stated remit, and it was allowed through.
if ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
raise EgressError(
f"refusing to open URL targeting non-routable/metadata address {host!r} "
Expand Down
19 changes: 19 additions & 0 deletions sentinel_harness/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,25 @@
lambda m: f"{m.group('key')}{m.group('sep')}{_REDACTED}"),
# AWS access key ids and long opaque secret-ish blobs
(re.compile(r"\b(?:AKIA|ASIA|ABSK)[0-9A-Za-z_-]{8,}\b"), lambda _m: _REDACTED),
# Provider-prefixed tokens with NO key name in front of them.
#
# The key=value rule above needs `token=`/`secret:` before the value, and the AWS rule covers
# only AKIA/ASIA/ABSK — so a BARE `sk-…` or `ghp_…` sailed through verbatim. Measured:
#
# token=sk-<24> -> token=[redacted]
# upstream rejected: sk-<24> -> upstream rejected: sk-<24> <-- leaked
# git push failed: ghp_<24> -> git push failed: ghp_<24> <-- leaked
#
# The unkeyed form is the COMMON one: an upstream echoes the credential it rejected straight into
# its error message, with no obliging `token=` label. And these two prefixes are not a standard I
# invented for this file — `.github/workflows/ci.yml`'s secret-and-name scan greps commits for
# exactly `sk-` / `ghp_` / `ABSK`, so the repo already treats them as credential shapes. The
# redactor was missing a class its own CI gate enforces.
#
# The prefix is kept in the output (`sk-[redacted]`) rather than swallowed: an operator reading a
# log needs to know WHICH credential to rotate, and the prefix is the only part that says so.
(re.compile(r"\b(?P<prefix>sk-|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_)[0-9A-Za-z_-]{16,}\b"),
lambda m: f"{m.group('prefix')}{_REDACTED}"),
# query strings can carry anything; drop the whole thing rather than guess
(re.compile(r"\?[^\s\"']{4,}"), lambda _m: f"?{_REDACTED}"),
)
Expand Down
Loading
Loading