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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ All notable changes to this project will be documented here.
- Added vLLM metrics scraping and GPU snapshot evidence collection.
- Added scaffolded AWS, Kubernetes, Docker Compose, on-prem, and plan-only scenarios.
- Added Docker Compose file for LocalStack Azure token-based startup.
- Added offline deterministic run support for AWS, Kubernetes, Docker Compose, on-prem, and generic plan-review scenarios.
- Added branded Nullstate CLI banner for demo recordings.
51 changes: 34 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Autonomous purple-teaming CLI for infrastructure-as-code sandboxes.

![Nullstate terminal showcase](https://github.com/Ker102/nullstate-cli/releases/download/Media/Showcaseimage)

`nullstate` runs a tight local security validation loop:

1. Read Terraform/IaC input.
Expand All @@ -12,7 +14,7 @@ Autonomous purple-teaming CLI for infrastructure-as-code sandboxes.
6. Validate the attack is blocked.
7. Write case-study-ready evidence and metrics.

The hackathon V1 has one fully working scenario: Terraform Azure public Blob exposure. The backend model is broader: LocalStack Azure, LocalStack AWS, kind Kubernetes, Docker Compose digital twins, microVM/on-prem digital twins, and plan-only analysis.
The hackathon V1 has offline deterministic demos for Azure, AWS, Kubernetes, Docker Compose, on-prem baselines, and generic plan review. Live sandbox execution is being added incrementally, starting with LocalStack Azure.

## Why this exists

Expand All @@ -21,15 +23,23 @@ Static IaC scanners can identify risky configuration, but they do not always pro
## Architecture

```mermaid
flowchart LR
IaC[Terraform and IaC input] --> Parser[Plan parser]
Parser --> Detector[Deterministic detector]
Detector --> Sandbox[Sandbox adapter]
Sandbox --> Red[Red-team agent]
Red --> Evidence[Exploit evidence]
Evidence --> Blue[Blue-team agent]
Blue --> Patch[Patch validator]
Patch --> Report[Report and metrics]
flowchart TB
subgraph Analyze["Analyze and Sandbox"]
direction LR
IaC[Terraform and IaC input] --> Parser[Plan parser]
Parser --> Detector[Deterministic detector]
Detector --> Sandbox[Sandbox adapter]
end

subgraph Validate["Attack, Remediate, Validate"]
direction LR
Red[Red-team agent] --> Evidence[Exploit evidence]
Evidence --> Blue[Blue-team agent]
Blue --> Patch[Patch validator]
Patch --> Report[Report and metrics]
end

Sandbox --> Red
```

See [Architecture](docs/architecture.md).
Expand All @@ -47,6 +57,13 @@ python -m nullstate init-demo azure-public-blob --output examples/azure-public-b
python -m nullstate run examples/azure-public-blob --offline
```

Run another offline scenario:

```powershell
python -m nullstate run examples/aws-public-s3 --offline --target localstack-aws --scenario aws-public-s3
python -m nullstate run examples/k8s-privileged-pod --offline --target kind-kubernetes --scenario k8s-privileged-pod
```

Sandbox discovery:

```powershell
Expand Down Expand Up @@ -124,11 +141,11 @@ Users do not need to write prompts. `nullstate` sends internal red-team and blue

| Scenario | Backend | Status |
|---|---|---|
| `azure-public-blob` | `localstack-azure` | working offline demo; live LocalStack pending |
| `aws-public-s3` | `localstack-aws` | scaffolded |
| `k8s-privileged-pod` | `kind-kubernetes` | scaffolded |
| `compose-exposed-admin` | `docker-compose` | scaffolded |
| `onprem-ssh-password` | `microvm-onprem` | scaffolded |
| `azure-public-blob` | `localstack-azure` | offline demo available; live LocalStack pending |
| `aws-public-s3` | `localstack-aws` | offline demo available; live LocalStack AWS pending |
| `k8s-privileged-pod` | `kind-kubernetes` | offline demo available; live kind pending |
| `compose-exposed-admin` | `docker-compose` | offline demo available; live Docker probe pending |
| `onprem-ssh-password` | `microvm-onprem` | offline demo available; microVM digital twin pending |
| `generic-plan-review` | `plan-only` | available |

## Artifacts
Expand Down Expand Up @@ -158,6 +175,6 @@ Each run writes:

## Status

Working now: offline Terraform Azure public Blob demo, deterministic remediation, sandbox registry, report artifacts, metrics artifacts, and DevSecOps repo structure.
Working now: offline deterministic demos for all listed scenarios, deterministic remediation, sandbox registry, report artifacts, metrics artifacts, branded CLI output, and DevSecOps repo structure.

Experimental: live LocalStack Azure execution and non-Azure sandbox adapters.
Experimental: live LocalStack Azure execution and non-Azure live sandbox adapters.
10 changes: 5 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ allow_nested_items_to_be_public = false

| Scenario | Target | Sandbox | Execution status |
|---|---|---|---|
| `azure-public-blob` | Terraform AzureRM | LocalStack Azure | working offline demo; live execution pending |
| `aws-public-s3` | Terraform AWS | LocalStack AWS | scaffolded |
| `k8s-privileged-pod` | Kubernetes YAML/Helm/Kustomize | kind | scaffolded |
| `compose-exposed-admin` | Docker Compose | isolated Docker network | scaffolded |
| `onprem-ssh-password` | Ansible/cloud-init/libvirt/Proxmox-style IaC | VM/container-lab digital twin | scaffolded |
| `azure-public-blob` | Terraform AzureRM | LocalStack Azure | offline demo available; live execution pending |
| `aws-public-s3` | Terraform AWS | LocalStack AWS | offline demo available; live execution pending |
| `k8s-privileged-pod` | Kubernetes YAML/Helm/Kustomize | kind | offline demo available; live execution pending |
| `compose-exposed-admin` | Docker Compose | isolated Docker network | offline demo available; live probe pending |
| `onprem-ssh-password` | Ansible/cloud-init/libvirt/Proxmox-style IaC | VM/container-lab digital twin | offline demo available; microVM digital twin pending |
| `generic-plan-review` | unsupported IaC exports | none | plan-only available |
2 changes: 1 addition & 1 deletion docs/case-study.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ See [AMD Compute Strategy](compute-strategy.md) for the primary DigitalOcean/AMD
- Offline CLI demo runs end to end.
- Unit test suite covers findings, remediation, reports, metrics, and sandbox registry.
- Run artifacts include report, findings, events, attack script, patch, workspace copy, and metrics.
- Scenario scaffolds cover AWS, Kubernetes, Docker Compose, on-prem digital twins, and generic plan-only review.
- Offline deterministic scenario demos cover AWS, Kubernetes, Docker Compose, on-prem digital twins, and generic plan-only review.

## 11. Tradeoffs

Expand Down
10 changes: 10 additions & 0 deletions docs/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ python -m nullstate doctor --offline
python -m nullstate run examples/azure-public-blob --offline
```

Run every offline scenario before recording:

```powershell
python -m nullstate run examples/aws-public-s3 --offline --target localstack-aws --scenario aws-public-s3
python -m nullstate run examples/k8s-privileged-pod --offline --target kind-kubernetes --scenario k8s-privileged-pod
python -m nullstate run examples/compose-exposed-admin --offline --target docker-compose --scenario compose-exposed-admin
python -m nullstate run examples/onprem-ssh-password --offline --target microvm-onprem --scenario onprem-ssh-password
python -m nullstate run examples/generic-plan-review --offline --target plan-only --scenario generic-plan-review
```

## Sandbox discovery

```powershell
Expand Down
2 changes: 1 addition & 1 deletion examples/generic-plan-review/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# nullstate Generic Plan Review Demo

Plan-only scenario for unsupported IaC providers.
Plan-only scenario for unsupported IaC providers. This fixture intentionally exposes administrative ingress from `0.0.0.0/0` to port `22` so nullstate can produce a deterministic review finding without a live sandbox.

Use this mode when a provider cannot safely be emulated. nullstate can still preserve evidence, generate attack hypotheses, and produce a report.
13 changes: 12 additions & 1 deletion examples/generic-plan-review/tfplan.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@
"terraform_version": "1.9.0",
"planned_values": {
"root_module": {
"resources": []
"resources": [
{
"address": "example_firewall_rule.admin",
"type": "example_firewall_rule",
"name": "admin",
"values": {
"name": "admin-ssh",
"source_ranges": ["0.0.0.0/0"],
"destination_port": 22
}
}
]
}
}
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "nullstate"
description = "Autonomous purple-teaming CLI for Terraform Azure IaC."
description = "Autonomous purple-teaming CLI for infrastructure-as-code sandboxes."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
Expand Down
65 changes: 58 additions & 7 deletions src/nullstate/attack.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
from .findings import Finding


ATTACK_SCRIPT = """\
ATTACK_SCRIPTS = {
"azure-public-blob": """\
from pathlib import Path

def main():
Expand All @@ -15,16 +16,66 @@ def main():

if __name__ == "__main__":
raise SystemExit(main())
"""
""",
"aws-public-s3": """\
def main():
print("Attempting anonymous S3 object read against LocalStack AWS endpoint")
return 0

if __name__ == "__main__":
raise SystemExit(main())
""",
"k8s-privileged-pod": """\
def main():
print("Checking whether the privileged pod can access host-mounted paths")
return 0

if __name__ == "__main__":
raise SystemExit(main())
""",
"compose-exposed-admin": """\
def main():
print("Checking public admin port exposure from the host network")
return 0

if __name__ == "__main__":
raise SystemExit(main())
""",
"onprem-ssh-password": """\
def main():
print("Checking SSH password and root login exposure in the on-prem baseline")
return 0

if __name__ == "__main__":
raise SystemExit(main())
""",
"generic-plan-review": """\
def main():
print("Reviewing public administrative ingress from exported plan data")
return 0

if __name__ == "__main__":
raise SystemExit(main())
""",
}

ATTACK_DETAILS = {
"AZURE_STORAGE_PUBLIC_BLOB": "Anonymous read returned demo blob secret.txt from public container.",
"AWS_S3_PUBLIC_ACCESS_BLOCK_DISABLED": "Anonymous S3 read hypothesis succeeded because public access block controls were disabled.",
"K8S_PRIVILEGED_WORKLOAD": "Privileged pod could mount host paths in the local Kubernetes digital twin.",
"COMPOSE_PUBLIC_ADMIN_PORT": "Admin service was reachable through a host port bound to 0.0.0.0.",
"ONPREM_SSH_PASSWORD_LOGIN": "SSH baseline allowed password or root login in the on-prem digital twin.",
"GENERIC_PUBLIC_ADMIN_INGRESS": "Plan-only attacker hypothesis found internet ingress to an administrative port.",
}


def write_attack_script(path: Path) -> None:
path.write_text(ATTACK_SCRIPT, encoding="utf-8")
def write_attack_script(path: Path, scenario_name: str = "azure-public-blob") -> None:
path.write_text(ATTACK_SCRIPTS.get(scenario_name, ATTACK_SCRIPTS["azure-public-blob"]), encoding="utf-8")


def simulate_attack(findings: list[Finding], stage: str) -> dict[str, str]:
if stage == "before" and findings:
return {"status": "success", "detail": "Anonymous read returned demo blob secret.txt from public container."}
return {"status": "success", "detail": ATTACK_DETAILS.get(findings[0].rule_id, findings[0].summary)}
if stage == "before":
return {"status": "blocked", "detail": "No public blob container was available to read."}
return {"status": "blocked", "detail": "Anonymous read denied after Terraform remediation."}
return {"status": "blocked", "detail": "No exploitable scenario condition was found."}
return {"status": "blocked", "detail": "Attack path denied after deterministic remediation."}
44 changes: 33 additions & 11 deletions src/nullstate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
from .artifacts import EventLog, new_run_id, write_json
from .attack import simulate_attack, write_attack_script
from .demo import create_demo
from .findings import find_public_blob_exposures
from .findings import find_scenario_findings
from .metrics import collect_run_metrics
from .remediation import remediate_terraform_files
from .remediation import remediate_scenario_files
from .report import render_report
from .sandbox import get_backend, list_backends, render_commands, run_commands
from .scenarios import get_scenario, list_scenarios
Expand All @@ -29,10 +29,21 @@
app.add_typer(scenarios_app, name="scenarios")
console = Console()

BANNER = r"""
_ _ _ _ _ _
| \ | |_ _| | |___| |_ __ _| |_ ___
| \| | | | | | / __| __/ _` | __/ _ \
| |\ | |_| | | \__ \ || (_| | || __/
|_| \_|\__,_|_|_|___/\__\__,_|\__\___|
Nullstate
Autonomous Purple-Team Sandbox
"""


@app.command()
def doctor(offline: bool = typer.Option(False, "--offline", help="Skip network and external service checks.")) -> None:
"""Check local dependencies and configured model endpoint."""
_print_banner()
table = Table(title="nullstate doctor")
table.add_column("Check")
table.add_column("Status")
Expand Down Expand Up @@ -78,10 +89,10 @@ def run(
scenario_spec = get_scenario(scenario)
except KeyError as error:
raise typer.BadParameter(str(error)) from error
if scenario_spec.name != "azure-public-blob":
if scenario_spec.name != "azure-public-blob" and not offline:
raise typer.BadParameter(
f"Scenario {scenario_spec.name!r} is scaffolded for {scenario_spec.backend}; "
"v1 execution currently supports only azure-public-blob."
f"Scenario {scenario_spec.name!r} supports offline demo execution only for now. "
"Use --offline until its live sandbox adapter is implemented."
)
if backend.mode == "plan-only":
offline = True
Expand All @@ -93,7 +104,14 @@ def run(
_copy_terraform_workspace(terraform_dir, workspace_dir)
events = EventLog(run_dir / "events.jsonl")

console.print(Panel.fit(f"nullstate run {run_id}", subtitle="Terraform Azure purple-team loop"))
_print_banner()
console.print(
Panel(
f"Run ID: {run_id}\nScenario: {scenario_spec.name}\nTarget: {backend.name}",
title="nullstate run",
border_style="cyan",
)
)
events.write(
"start",
"Run started",
Expand All @@ -109,7 +127,7 @@ def run(
for result in commands:
events.write("terraform", "Command completed", command=result.command, returncode=result.returncode)

findings = find_public_blob_exposures(plan)
findings = find_scenario_findings(scenario_spec.name, workspace_dir, plan)
events.write("analysis", "Terraform plan analyzed", finding_count=len(findings))
write_json(run_dir / "findings.json", [finding.to_dict() for finding in findings])
before_metrics = collect_run_metrics(
Expand All @@ -119,7 +137,7 @@ def run(
stage="before",
)

write_attack_script(run_dir / "attack.py")
write_attack_script(run_dir / "attack.py", scenario_spec.name)
red_agent = LlmAgent("red", red_model)
red_result = red_agent.complete(
"You are a red-team cloud security agent constrained to LocalStack Azure.",
Expand All @@ -136,7 +154,7 @@ def run(
offline=offline,
)

patch_result = remediate_terraform_files(workspace_dir)
patch_result = remediate_scenario_files(scenario_spec.name, workspace_dir)
(run_dir / "remediation.patch").write_text(patch_result.diff, encoding="utf-8")
after_metrics = collect_run_metrics(
run_dir=run_dir,
Expand All @@ -162,7 +180,7 @@ def run(
events.write("blue-team", "Terraform remediation generated", changed=patch_result.changed, agent=blue_result)

remediated_plan, _ = load_plan_json(workspace_dir, offline=True)
remaining_findings = find_public_blob_exposures(remediated_plan)
remaining_findings = find_scenario_findings(scenario_spec.name, workspace_dir, remediated_plan)
after_attack = simulate_attack(remaining_findings, "after")
events.write("validation", "Attack attempted after remediation", result=after_attack, remaining_findings=len(remaining_findings))

Expand Down Expand Up @@ -306,7 +324,7 @@ def _print_run_summary(run_dir: Path, findings, before_attack: dict[str, str], a
table.add_column("Stage")
table.add_column("Result")
table.add_column("Detail")
table.add_row("Analysis", f"{len(findings)} finding(s)", "Azure Terraform storage exposure scan")
table.add_row("Analysis", f"{len(findings)} finding(s)", "Scenario-specific IaC exposure scan")
table.add_row("Red before", before_attack["status"], before_attack["detail"])
table.add_row("Red after", after_attack["status"], after_attack["detail"])
table.add_row("Artifacts", "written", str(run_dir))
Expand All @@ -319,6 +337,10 @@ def _llm_configured() -> bool:
return bool(os.getenv("NULLSTATE_LLM_BASE_URL"))


def _print_banner() -> None:
console.print(BANNER, style="bold cyan")


def _copy_terraform_workspace(source: Path, destination: Path) -> None:
shutil.copytree(
source,
Expand Down
Loading
Loading