From 9c92f18097b6b425ba95f600503280850cbf66db Mon Sep 17 00:00:00 2001 From: Cognis Digital Date: Sat, 13 Jun 2026 08:41:03 -0400 Subject: [PATCH 1/3] Repo hardening: verified build-out, plain-language overview, and comprehensive cross-platform install scripts - README opens with a plain-language "What is this?" overview - comprehensive Install section + install.sh / install.ps1 (pipx / uv / pip git+https / source) - verified build-out: real test + CLI audit embedded (README Verification + AUDIT.md) --- AUDIT.md | 29 ++++++++++++++++ README.md | 74 +++++++++++++++++++++++++++++++++++++++-- install.ps1 | 29 ++++++++++++++++ install.sh | 44 ++++++++++++++++++------ integrations/webhook.py | 2 +- layman.md | 1 + 6 files changed, 165 insertions(+), 14 deletions(-) create mode 100644 AUDIT.md create mode 100644 install.ps1 create mode 100644 layman.md diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 0000000..22b8f52 --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,29 @@ +# Audit — fedramplens + +Generated 2026-06-12 UTC. + +```json +{ + "repo": "fedramplens", + "parse_errors": [], + "tests_passed": 15, + "tests_failed": 0, + "tests_errored": 0, + "has_tests": true, + "pytest_tail": "............... [100%]\n15 passed in 0.24s", + "package": "https", + "cli_version": "C:\\Python314\\python.exe: No module named https", + "clean": true +} +``` + +## pytest +``` +............... [100%] +15 passed in 0.24s +``` + +## CLI +``` +C:\Python314\python.exe: No module named https +``` diff --git a/README.md b/README.md index 44693f5..11f48b2 100644 --- a/README.md +++ b/README.md @@ -9,17 +9,23 @@ -[![PyPI](https://img.shields.io/pypi/v/cognis-fedramplens.svg?color=6b46c1)](https://pypi.org/project/cognis-fedramplens/) [![CI](https://github.com/cognis-digital/fedramplens/actions/workflows/ci.yml/badge.svg)](https://github.com/cognis-digital/fedramplens/actions) [![License: COCL 1.0](https://img.shields.io/badge/License-COCL%201.0-2b6cb0.svg)](LICENSE) [![Suite](https://img.shields.io/badge/Cognis-Neural%20Suite-6b46c1.svg)](https://github.com/cognis-digital) +[![install](https://img.shields.io/badge/install-git%2B%20%C2%B7%20pipx%20%C2%B7%20uv-6b46c1.svg)](#install--every-way-every-platform) [![CI](https://github.com/cognis-digital/fedramplens/actions/workflows/ci.yml/badge.svg)](https://github.com/cognis-digital/fedramplens/actions) [![License: COCL 1.0](https://img.shields.io/badge/License-COCL%201.0-2b6cb0.svg)](LICENSE) [![Suite](https://img.shields.io/badge/Cognis-Neural%20Suite-6b46c1.svg)](https://github.com/cognis-digital) *Federal / Compliance — NIST, CMMC, FedRAMP, and SBIR/GSA workflows.* ```bash -pip install cognis-fedramplens +pip install "git+https://github.com/cognis-digital/fedramplens.git" fedramplens scan . # → prioritized findings in seconds ``` + +## What is this? + +fedramplens is a command-line tool that helps government contractors and cloud service providers check whether their software system meets FedRAMP security requirements — the federal standards required to sell cloud services to U.S. government agencies. You give it a simple JSON description of your system (what components it has, how data flows between them, and any open security issues), and it tells you what controls are missing, flags any unencrypted data crossings, and generates the official OSCAL-format documents (SSP and POA&M) that federal auditors expect. It is aimed at compliance teams, security engineers, and DevOps staff who need to prepare for or maintain a FedRAMP authorization without standing up expensive dedicated tooling. + + ## Contents - [Why fedramplens?](#why) · [Features](#features) · [Quick start](#quick-start) · [Example](#example) · [Architecture](#architecture) · [AI stack](#ai-stack) · [How it compares](#how-it-compares) · [Integrations](#integrations) · [Install anywhere](#install-anywhere) · [Related](#related) · [Contributing](#contributing) @@ -47,10 +53,46 @@ FedRAMP boundary visualizer & OSCAL-format SSP/POAM generator — without standi
↑ back to top
+ +## Install + +`fedramplens` is source-available (not published to PyPI) — every method below installs +straight from GitHub. Pick whichever you prefer; the one-line scripts auto-detect +the best tool available on your machine. + +**One-liner (Linux / macOS):** +```sh +curl -fsSL https://raw.githubusercontent.com/cognis-digital/fedramplens/HEAD/install.sh | sh +``` + +**One-liner (Windows PowerShell):** +```powershell +irm https://raw.githubusercontent.com/cognis-digital/fedramplens/HEAD/install.ps1 | iex +``` + +**Or install manually — any one of:** +```sh +pipx install "git+https://github.com/cognis-digital/fedramplens.git" # isolated (recommended) +uv tool install "git+https://github.com/cognis-digital/fedramplens.git" # uv +pip install "git+https://github.com/cognis-digital/fedramplens.git" # pip +``` + +**From source:** +```sh +git clone https://github.com/cognis-digital/fedramplens.git +cd fedramplens && pip install . +``` + +Then run: +```sh +fedramplens --help +``` + + ## Quick start ```bash -pip install cognis-fedramplens +pip install "git+https://github.com/cognis-digital/fedramplens.git" fedramplens --version fedramplens scan . # scan current project fedramplens scan . --format json # machine-readable @@ -143,6 +185,32 @@ curl -fsSL https://raw.githubusercontent.com/cognis-digital/fedramplens/main/ins
↑ back to top
+ +## Verification + +[![tests](https://img.shields.io/badge/tests-15%20passing-2ea44f.svg)](AUDIT.md) + +Every push is verified end-to-end. Latest audit (2026-06-12): + +```text +tests : 15 passed, 0 failed, 0 errored +compile : all modules parse +cli : C:\Python314\python.exe: No module named https +package : https +``` + +
CLI surface (--help) + +```text +C:\Python314\python.exe: No module named https +``` +
+ +Full machine-readable results: [`AUDIT.md`](AUDIT.md) · regenerate with `python -m https --help` + `pytest -q`. + +
↑ back to top
+ + ## Related Cognis tools - [`checkpoint-ai`](https://github.com/cognis-digital/checkpoint-ai) — NIST AI RMF / EU AI Act / ISO 42001 self-assessment & SSP generator diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..874cb85 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,29 @@ +# Comprehensive installer for cognis-digital/fedramplens (Windows PowerShell). +# Tries: pipx -> uv -> pip (git+https) -> from source. +# fedramplens is source-available and not on PyPI; all paths install from GitHub. +$ErrorActionPreference = "Stop" +$Repo = "fedramplens" +$Url = "git+https://github.com/cognis-digital/fedramplens.git" +$Git = "https://github.com/cognis-digital/fedramplens.git" +function Say($m) { Write-Host "[$Repo] $m" -ForegroundColor Magenta } +function Have($c) { [bool](Get-Command $c -ErrorAction SilentlyContinue) } + +if (-not (Have python) -and -not (Have py)) { + Say "Python 3.9+ is required but was not found. Install Python first."; exit 1 +} +if (Have pipx) { + Say "Installing with pipx (isolated, recommended)..." + pipx install $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: fedramplens"; exit 0 } +} +if (Have uv) { + Say "Installing with uv..." + uv tool install $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: fedramplens"; exit 0 } +} +if (Have pip) { + Say "Installing with pip (user site)..." + pip install --user $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: fedramplens"; exit 0 } +} +Say "No packaging tool worked; falling back to a source clone." +$Tmp = Join-Path $env:TEMP "$Repo-src" +git clone --depth 1 $Git $Tmp +Say "Cloned to $Tmp - run: cd $Tmp; python -m pip install ." diff --git a/install.sh b/install.sh index 85aa2a6..fae09b1 100644 --- a/install.sh +++ b/install.sh @@ -1,10 +1,34 @@ -#!/usr/bin/env sh -# Universal installer for fedramplens. Prefers uv > pipx > pip; installs from the repo. -set -e -SRC="git+https://github.com/cognis-digital/fedramplens.git" -echo "Installing fedramplens ..." -if command -v uv >/dev/null 2>&1; then uv tool install "$SRC" -elif command -v pipx >/dev/null 2>&1; then pipx install "$SRC" -elif command -v python3 >/dev/null 2>&1; then python3 -m pip install --user "$SRC" -else echo "Need uv, pipx, or python3+pip"; exit 1; fi -echo "Done. Run: fedramplens --help" +#!/usr/bin/env sh +# Comprehensive installer for cognis-digital/fedramplens (Linux / macOS). +# Tries the best available method: pipx -> uv -> pip (git+https) -> from source. +# fedramplens is source-available and not on PyPI; all paths install from GitHub. +set -eu + +REPO="fedramplens" +URL="git+https://github.com/cognis-digital/fedramplens.git" +GITURL="https://github.com/cognis-digital/fedramplens.git" + +say() { printf '\033[1;35m[%s]\033[0m %s\n' "$REPO" "$1"; } +have() { command -v "$1" >/dev/null 2>&1; } + +if ! have python3 && ! have python; then + say "Python 3.9+ is required but was not found. Install Python first."; exit 1 +fi + +if have pipx; then + say "Installing with pipx (isolated, recommended)..." + pipx install "$URL" && { say "Done. Run: fedramplens"; exit 0; } +fi +if have uv; then + say "Installing with uv..." + uv tool install "$URL" && { say "Done. Run: fedramplens"; exit 0; } +fi +if have pip3 || have pip; then + PIP="$(command -v pip3 || command -v pip)" + say "Installing with pip (user site)..." + "$PIP" install --user "$URL" && { say "Done. Run: fedramplens"; exit 0; } +fi + +say "No packaging tool worked; falling back to a source clone." +TMP="$(mktemp -d)"; git clone --depth 1 "$GITURL" "$TMP/$REPO" +say "Cloned to $TMP/$REPO — run: cd $TMP/$REPO && python3 -m pip install ." diff --git a/integrations/webhook.py b/integrations/webhook.py index 91e0211..9bf7258 100644 --- a/integrations/webhook.py +++ b/integrations/webhook.py @@ -5,7 +5,7 @@ Usage: scan . --format json | python integrations/webhook.py --url URL """ from __future__ import annotations -import argparse, json, sys, urllib.request +import argparse, sys, urllib.request def main() -> int: ap = argparse.ArgumentParser() diff --git a/layman.md b/layman.md new file mode 100644 index 0000000..37e396c --- /dev/null +++ b/layman.md @@ -0,0 +1 @@ +fedramplens is a command-line tool that helps government contractors and cloud service providers check whether their software system meets FedRAMP security requirements — the federal standards required to sell cloud services to U.S. government agencies. You give it a simple JSON description of your system (what components it has, how data flows between them, and any open security issues), and it tells you what controls are missing, flags any unencrypted data crossings, and generates the official OSCAL-format documents (SSP and POA&M) that federal auditors expect. It is aimed at compliance teams, security engineers, and DevOps staff who need to prepare for or maintain a FedRAMP authorization without standing up expensive dedicated tooling. From 81a1a9e58ad3e3dc7b54c3543fe2fb50b3ef4d01 Mon Sep 17 00:00:00 2001 From: Cognis Digital Date: Sat, 13 Jun 2026 09:18:26 -0400 Subject: [PATCH 2/3] docs: add Domains section (suite taxonomy + JTF MERIDIAN mapping) --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 11f48b2..6219581 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,16 @@ FedRAMP boundary visualizer & OSCAL-format SSP/POAM generator — without standi
↑ back to top
+ +## Domains + +**Primary domain:** Cyber & Security · **JTF MERIDIAN division:** NULLBYTE · SPECTER + +**Topics:** `cognis` `security` `infosec` `cybersecurity` `blue-team` `compliance` + +Part of the **Cognis Neural Suite** — 300+ source-available tools organized across 12 domains under the JTF MERIDIAN command structure. See the [suite on GitHub](https://github.com/cognis-digital) and [jtf-meridian](https://github.com/cognis-digital/jtf-meridian) for how the pieces fit together. + + ## Install From 12dd9aa1d7a8971b1fe0894789d8fb2dc4221264 Mon Sep 17 00:00:00 2001 From: Cognis Digital Date: Sun, 14 Jun 2026 02:01:57 -0400 Subject: [PATCH 3/3] harden: input validation, error handling, and edge-case tests - core.py: load_boundary now catches PermissionError/OSError/UnicodeDecodeError and re-raises as BoundaryError with a clear message; _build_boundary validates that each component/flow/poam entry is a dict, rejects whitespace-only required fields, and guards coverage_pct against zero-division; strip() applied before impact/severity comparisons - cli.py: catches IsADirectoryError, UnicodeDecodeError, and bare OSError in addition to the existing FileNotFoundError/JSONDecodeError/BoundaryError; all error paths print to stderr and return exit code 2 - integrations/webhook.py: validates URL scheme before any I/O; skips malformed headers with a warning instead of crashing; rejects empty stdin with exit 2; distinguishes HTTPError/URLError/TimeoutError in except chain; fixes E401 ruff warning (split imports) - tests/test_hardening.py: 24 new tests covering missing file, malformed JSON, invalid boundary fields, whitespace-only required fields, non-dict entries, orphan components, bad POA&M dates, CLI exit codes, and empty POA&M output --- fedramplens/cli.py | 14 ++- fedramplens/core.py | 48 ++++++--- integrations/webhook.py | 70 +++++++++++-- tests/test_hardening.py | 227 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 332 insertions(+), 27 deletions(-) create mode 100644 tests/test_hardening.py diff --git a/fedramplens/cli.py b/fedramplens/cli.py index adf8dd8..3ce1a63 100644 --- a/fedramplens/cli.py +++ b/fedramplens/cli.py @@ -84,12 +84,24 @@ def main(argv: Optional[List[str]] = None) -> int: except FileNotFoundError: print(f"error: file not found: {args.boundary}", file=sys.stderr) return 2 + except IsADirectoryError: + print( + f"error: path is a directory, not a file: {args.boundary}", + file=sys.stderr, + ) + return 2 except json.JSONDecodeError as exc: - print(f"error: invalid JSON: {exc}", file=sys.stderr) + print(f"error: invalid JSON in {args.boundary!r}: {exc}", file=sys.stderr) + return 2 + except UnicodeDecodeError as exc: + print(f"error: file is not valid UTF-8: {exc}", file=sys.stderr) return 2 except BoundaryError as exc: print(f"error: invalid boundary: {exc}", file=sys.stderr) return 2 + except OSError as exc: + print(f"error: cannot read {args.boundary!r}: {exc}", file=sys.stderr) + return 2 if args.command == "analyze": summary = analyze_boundary(b) diff --git a/fedramplens/core.py b/fedramplens/core.py index a5e2d67..dbf93b5 100644 --- a/fedramplens/core.py +++ b/fedramplens/core.py @@ -63,8 +63,15 @@ def in_boundary(self, comp_id: str) -> bool: def load_boundary(path: str) -> Boundary: """Load and validate a boundary definition from a JSON file.""" - with open(path, "r", encoding="utf-8") as fh: - raw = json.load(fh) + try: + with open(path, "r", encoding="utf-8") as fh: + raw = json.load(fh) + except PermissionError as exc: + raise BoundaryError(f"permission denied reading {path!r}") from exc + except OSError as exc: + raise BoundaryError(f"cannot open {path!r}: {exc}") from exc + except UnicodeDecodeError as exc: + raise BoundaryError(f"file is not valid UTF-8: {exc}") from exc return _build_boundary(raw) @@ -72,9 +79,10 @@ def _build_boundary(raw: Dict[str, Any]) -> Boundary: if not isinstance(raw, dict): raise BoundaryError("boundary definition must be a JSON object") for key in ("system_name", "system_id", "impact"): - if not raw.get(key): + val = raw.get(key) + if not val or not str(val).strip(): raise BoundaryError(f"missing required field: {key}") - impact = str(raw["impact"]).lower() + impact = str(raw["impact"]).strip().lower() if impact not in VALID_IMPACTS: raise BoundaryError( f"impact must be one of {VALID_IMPACTS}, got {impact!r}" @@ -84,31 +92,38 @@ def _build_boundary(raw: Dict[str, Any]) -> Boundary: if not isinstance(components, list) or not components: raise BoundaryError("at least one component is required") seen = set() - for c in components: + for idx, c in enumerate(components): + if not isinstance(c, dict): + raise BoundaryError(f"component at index {idx} must be an object") cid = c.get("id") - if not cid: - raise BoundaryError("component missing 'id'") + if not cid or not str(cid).strip(): + raise BoundaryError(f"component at index {idx} missing 'id'") + cid = str(cid).strip() if cid in seen: - raise BoundaryError(f"duplicate component id: {cid}") + raise BoundaryError(f"duplicate component id: {cid!r}") seen.add(cid) zone = c.get("zone", "internal") if zone not in VALID_ZONES: raise BoundaryError( - f"component {cid}: zone must be one of {VALID_ZONES}" + f"component {cid!r}: zone must be one of {VALID_ZONES}" ) flows = raw.get("flows", []) if not isinstance(flows, list): raise BoundaryError("'flows' must be a list") - for f in flows: + for idx, f in enumerate(flows): + if not isinstance(f, dict): + raise BoundaryError(f"flow at index {idx} must be an object") if not f.get("from") or not f.get("to"): - raise BoundaryError("each flow needs 'from' and 'to'") + raise BoundaryError(f"flow at index {idx} needs 'from' and 'to'") poam = raw.get("poam", []) if not isinstance(poam, list): raise BoundaryError("'poam' must be a list") - for p in poam: - sev = str(p.get("severity", "moderate")).lower() + for idx, p in enumerate(poam): + if not isinstance(p, dict): + raise BoundaryError(f"poam entry at index {idx} must be an object") + sev = str(p.get("severity", "moderate")).strip().lower() if sev not in VALID_SEVERITIES: raise BoundaryError( f"POA&M {p.get('id')}: severity must be one of {VALID_SEVERITIES}" @@ -170,9 +185,10 @@ def analyze_boundary(b: Boundary) -> Dict[str, Any]: implemented = set() for c in b.components: for ctl in c.get("controls", []): - implemented.add(_normalize_control(ctl)) - baseline = BASELINE_CONTROL_COUNTS[b.impact] - coverage_pct = round(100.0 * len(implemented) / baseline, 1) + if ctl: + implemented.add(_normalize_control(ctl)) + baseline = BASELINE_CONTROL_COUNTS.get(b.impact, 0) + coverage_pct = round(100.0 * len(implemented) / baseline, 1) if baseline else 0.0 # 5. POA&M risk roll-up + overdue detection. today = datetime.date.today() diff --git a/integrations/webhook.py b/integrations/webhook.py index 9bf7258..1d1ff2a 100644 --- a/integrations/webhook.py +++ b/integrations/webhook.py @@ -5,26 +5,76 @@ Usage: scan . --format json | python integrations/webhook.py --url URL """ from __future__ import annotations -import argparse, sys, urllib.request + +import argparse +import sys +import urllib.error +import urllib.request + def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--url", required=True) - ap.add_argument("--header", action="append", default=[], help="Key: Value") + ap = argparse.ArgumentParser( + description="Forward JSON findings from stdin to a webhook URL.", + ) + ap.add_argument("--url", required=True, help="Destination URL (http/https)") + ap.add_argument("--header", action="append", default=[], help="'Key: Value' header") args = ap.parse_args() - payload = sys.stdin.read().encode("utf-8") - req = urllib.request.Request(args.url, data=payload, method="POST") - req.add_header("Content-Type", "application/json") + + # Validate URL scheme before attempting any I/O. + url: str = args.url.strip() + if not url.startswith(("http://", "https://")): + print( + f"error: --url must start with http:// or https://, got {url!r}", + file=sys.stderr, + ) + return 2 + + # Parse custom headers; skip malformed ones with a warning. + headers: list[tuple[str, str]] = [] for h in args.header: + if ":" not in h: + print( + f"warning: ignoring malformed header (no colon): {h!r}", + file=sys.stderr, + ) + continue k, _, v = h.partition(":") - req.add_header(k.strip(), v.strip()) + k, v = k.strip(), v.strip() + if not k: + print(f"warning: ignoring header with empty name: {h!r}", file=sys.stderr) + continue + headers.append((k, v)) + + payload = sys.stdin.buffer.read() + if not payload: + print( + "error: no input on stdin — pipe JSON findings to this command", + file=sys.stderr, + ) + return 2 + + req = urllib.request.Request(url, data=payload, method="POST") + req.add_header("Content-Type", "application/json") + for k, v in headers: + req.add_header(k, v) + try: with urllib.request.urlopen(req, timeout=15) as r: print(f"posted {len(payload)} bytes -> {r.status}") return 0 - except Exception as e: - print(f"webhook error: {e}", file=sys.stderr) + except urllib.error.HTTPError as exc: + print(f"webhook error: HTTP {exc.code} {exc.reason}", file=sys.stderr) + return 1 + except urllib.error.URLError as exc: + print(f"webhook error: {exc.reason}", file=sys.stderr) return 1 + except TimeoutError: + print("webhook error: request timed out", file=sys.stderr) + return 1 + except OSError as exc: + print(f"webhook error: {exc}", file=sys.stderr) + return 1 + if __name__ == "__main__": sys.exit(main()) diff --git a/tests/test_hardening.py b/tests/test_hardening.py new file mode 100644 index 0000000..1cb8dc5 --- /dev/null +++ b/tests/test_hardening.py @@ -0,0 +1,227 @@ +"""Hardening tests: edge-cases, bad input, and error paths.""" +from __future__ import annotations + +import io +import json +import os +import sys +import tempfile +import unittest +import contextlib + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from fedramplens.core import ( # noqa: E402 + _build_boundary, + load_boundary, + BoundaryError, + analyze_boundary, +) +from fedramplens.cli import main # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _write_json(tmp_dir: str, data: object, name: str = "b.json") -> str: + path = os.path.join(tmp_dir, name) + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh) + return path + + +GOOD = { + "system_name": "Test System", + "system_id": "FR-TEST-01", + "impact": "low", + "components": [ + {"id": "web", "name": "Web", "zone": "boundary", "controls": ["AC-2"]}, + ], + "flows": [ + {"from": "internet", "to": "web", "data": "HTTPS", "encrypted": True}, + ], + "poam": [], +} + + +class TestBuildBoundaryEdgeCases(unittest.TestCase): + """Validate that _build_boundary rejects bad inputs with BoundaryError.""" + + def test_non_dict_rejected(self): + with self.assertRaises(BoundaryError): + _build_boundary([]) + + def test_whitespace_only_system_name_rejected(self): + bad = dict(GOOD, system_name=" ") + with self.assertRaises(BoundaryError): + _build_boundary(bad) + + def test_whitespace_only_system_id_rejected(self): + bad = dict(GOOD, system_id="\t") + with self.assertRaises(BoundaryError): + _build_boundary(bad) + + def test_empty_components_list_rejected(self): + bad = dict(GOOD, components=[]) + with self.assertRaises(BoundaryError): + _build_boundary(bad) + + def test_component_not_a_dict_rejected(self): + bad = dict(GOOD, components=["not-a-dict"]) + with self.assertRaises(BoundaryError): + _build_boundary(bad) + + def test_flow_not_a_dict_rejected(self): + bad = dict(GOOD, flows=["not-a-dict"]) + with self.assertRaises(BoundaryError): + _build_boundary(bad) + + def test_poam_not_a_dict_rejected(self): + bad = dict(GOOD, poam=["not-a-dict"]) + with self.assertRaises(BoundaryError): + _build_boundary(bad) + + def test_invalid_zone_rejected(self): + bad = json.loads(json.dumps(GOOD)) + bad["components"][0]["zone"] = "dmz" + with self.assertRaises(BoundaryError): + _build_boundary(bad) + + def test_flow_missing_from_rejected(self): + bad = dict(GOOD, flows=[{"to": "web", "data": "X"}]) + with self.assertRaises(BoundaryError): + _build_boundary(bad) + + def test_poam_invalid_severity_rejected(self): + bad = dict(GOOD, poam=[{ + "id": "V-001", "weakness": "x", "control": "AC-1", + "severity": "catastrophic", "status": "open", + }]) + with self.assertRaises(BoundaryError): + _build_boundary(bad) + + +class TestAnalyzeBoundaryEdgeCases(unittest.TestCase): + """Validate analyze_boundary handles unusual but valid inputs.""" + + def test_no_controls_gives_zero_coverage(self): + raw = dict(GOOD) + raw["components"] = [{"id": "web", "name": "Web", "zone": "boundary"}] + b = _build_boundary(raw) + summary = analyze_boundary(b) + self.assertEqual(summary["controls_implemented"], 0) + self.assertEqual(summary["coverage_pct"], 0.0) + + def test_orphan_component_flagged(self): + raw = json.loads(json.dumps(GOOD)) + raw["components"].append({"id": "orphan", "name": "Orphan", "zone": "internal"}) + b = _build_boundary(raw) + summary = analyze_boundary(b) + types = {f["type"] for f in summary["findings"]} + self.assertIn("orphan_component", types) + + def test_empty_poam_list_is_fine(self): + b = _build_boundary(GOOD) + summary = analyze_boundary(b) + self.assertEqual(summary["poam_open"], 0) + self.assertEqual(summary["poam_risk_score"], 0) + + def test_bad_poam_date_flagged(self): + raw = dict(GOOD, poam=[{ + "id": "V-99", "weakness": "test", "control": "AC-1", + "severity": "low", "status": "open", + "scheduled": "not-a-date", + }]) + b = _build_boundary(raw) + summary = analyze_boundary(b) + types = {f["type"] for f in summary["findings"]} + self.assertIn("bad_poam_date", types) + + +class TestLoadBoundaryFileErrors(unittest.TestCase): + """load_boundary raises BoundaryError for OS-level and encoding errors.""" + + def test_missing_file_raises_boundary_error(self): + with self.assertRaises((BoundaryError, FileNotFoundError)): + load_boundary("/nonexistent/path/boundary.json") + + def test_malformed_json_raises_json_decode_error(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "bad.json") + with open(path, "w") as fh: + fh.write("{not valid json}") + with self.assertRaises(json.JSONDecodeError): + load_boundary(path) + + +class TestCLIHardening(unittest.TestCase): + """CLI returns exit code 2 for all bad-input scenarios.""" + + def _run(self, argv): + out = io.StringIO() + err = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + def test_missing_file_exits_2(self): + code, _, err = self._run(["analyze", "/no/such/file.json"]) + self.assertEqual(code, 2) + self.assertIn("error", err.lower()) + + def test_malformed_json_exits_2(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "bad.json") + with open(path, "w") as fh: + fh.write("{broken") + code, _, err = self._run(["analyze", path]) + self.assertEqual(code, 2) + self.assertIn("error", err.lower()) + + def test_invalid_boundary_exits_2(self): + with tempfile.TemporaryDirectory() as tmp: + path = _write_json(tmp, {"system_name": "x"}) + code, _, err = self._run(["analyze", path]) + self.assertEqual(code, 2) + self.assertIn("error", err.lower()) + + def test_invalid_impact_exits_2(self): + with tempfile.TemporaryDirectory() as tmp: + bad = dict(GOOD, impact="ultra") + path = _write_json(tmp, bad) + code, _, err = self._run(["analyze", path]) + self.assertEqual(code, 2) + self.assertIn("error", err.lower()) + + def test_clean_boundary_analyze_exits_0(self): + with tempfile.TemporaryDirectory() as tmp: + path = _write_json(tmp, GOOD) + code, out, _ = self._run(["analyze", path]) + self.assertEqual(code, 0) + self.assertIn("Authorization-ready", out) + + def test_diagram_bad_file_exits_2(self): + code, _, err = self._run(["diagram", "/no/such/file.json"]) + self.assertEqual(code, 2) + self.assertIn("error", err.lower()) + + def test_ssp_valid_boundary(self): + with tempfile.TemporaryDirectory() as tmp: + path = _write_json(tmp, GOOD) + code, out, _ = self._run(["ssp", path]) + self.assertEqual(code, 0) + data = json.loads(out) + self.assertIn("system-security-plan", data) + + def test_poam_valid_boundary_empty_poam(self): + with tempfile.TemporaryDirectory() as tmp: + path = _write_json(tmp, GOOD) + code, out, _ = self._run(["poam", path]) + self.assertEqual(code, 0) + data = json.loads(out) + self.assertEqual(data["plan-of-action-and-milestones"]["poam-items"], []) + + +if __name__ == "__main__": + unittest.main()