posturanet 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- posturanet/__init__.py +3 -0
- posturanet/__main__.py +3 -0
- posturanet/aws/__init__.py +3 -0
- posturanet/aws/readonly.py +96 -0
- posturanet/aws/session.py +55 -0
- posturanet/cli/__init__.py +0 -0
- posturanet/cli/app.py +283 -0
- posturanet/contracts/__init__.py +18 -0
- posturanet/contracts/builder.py +63 -0
- posturanet/contracts/invariants.py +7 -0
- posturanet/contracts/store.py +135 -0
- posturanet/deliver/__init__.py +4 -0
- posturanet/deliver/checks.py +98 -0
- posturanet/deliver/github.py +231 -0
- posturanet/deliver/idempotency.py +49 -0
- posturanet/deliver/pr_body.py +218 -0
- posturanet/errors.py +39 -0
- posturanet/evidence/__init__.py +25 -0
- posturanet/evidence/confidence.py +48 -0
- posturanet/evidence/flowlogs.py +296 -0
- posturanet/evidence/periodicity.py +41 -0
- posturanet/gate.py +243 -0
- posturanet/hooks.py +85 -0
- posturanet/ingest/__init__.py +20 -0
- posturanet/ingest/asff.py +132 -0
- posturanet/ingest/controls.json +17 -0
- posturanet/ingest/controls.py +17 -0
- posturanet/ingest/manual.py +39 -0
- posturanet/locate/__init__.py +145 -0
- posturanet/locate/backend.py +67 -0
- posturanet/locate/codeowners.py +54 -0
- posturanet/locate/hcl_index.py +103 -0
- posturanet/locate/tfstate.py +94 -0
- posturanet/mcp/__init__.py +0 -0
- posturanet/mcp/server.py +119 -0
- posturanet/models/__init__.py +41 -0
- posturanet/models/common.py +25 -0
- posturanet/models/config.py +104 -0
- posturanet/models/contract.py +59 -0
- posturanet/models/finding.py +40 -0
- posturanet/models/location.py +23 -0
- posturanet/models/observations.py +56 -0
- posturanet/models/run.py +55 -0
- posturanet/models/validation.py +66 -0
- posturanet/output/__init__.py +0 -0
- posturanet/output/banner.py +98 -0
- posturanet/output/console.py +77 -0
- posturanet/output/symbols.py +29 -0
- posturanet/pipeline.py +510 -0
- posturanet/proposal.py +53 -0
- posturanet/py.typed +0 -0
- posturanet/synth/__init__.py +13 -0
- posturanet/synth/hcl_render.py +160 -0
- posturanet/synth/sg_rules.py +300 -0
- posturanet/synth/sg_z3.py +157 -0
- posturanet/validate/__init__.py +4 -0
- posturanet/validate/guardrails.py +33 -0
- posturanet/validate/invariants.py +112 -0
- posturanet/validate/plan_scope.py +71 -0
- posturanet/validate/recheck.py +71 -0
- posturanet/validate/runner.py +273 -0
- posturanet/validate/tf.py +92 -0
- posturanet-0.1.0.dist-info/METADATA +85 -0
- posturanet-0.1.0.dist-info/RECORD +67 -0
- posturanet-0.1.0.dist-info/WHEEL +4 -0
- posturanet-0.1.0.dist-info/entry_points.txt +2 -0
- posturanet-0.1.0.dist-info/licenses/LICENSE.md +105 -0
posturanet/__init__.py
ADDED
posturanet/__main__.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Read-only AWS access (NFR-2, architecture doc 7.3).
|
|
2
|
+
|
|
3
|
+
Every AWS call in PosturaNet goes through `ReadOnlyClient`. Operations not on the allowlist
|
|
4
|
+
raise `MutatingCallBlocked` before anything reaches the network. The generated IAM policy
|
|
5
|
+
(`iam_policy()`) is built from the same list so the two can't drift apart.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from posturanet.errors import PosturaNetError
|
|
15
|
+
|
|
16
|
+
ALLOWLIST: tuple[str, ...] = (
|
|
17
|
+
"ec2:Describe*",
|
|
18
|
+
"logs:StartQuery",
|
|
19
|
+
"logs:GetQueryResults",
|
|
20
|
+
"logs:DescribeLogGroups",
|
|
21
|
+
"athena:StartQueryExecution",
|
|
22
|
+
"athena:GetQueryExecution",
|
|
23
|
+
"athena:GetQueryResults",
|
|
24
|
+
"s3:GetObject",
|
|
25
|
+
"s3:ListBucket",
|
|
26
|
+
"iam:Get*",
|
|
27
|
+
"iam:List*",
|
|
28
|
+
"iam:GenerateServiceLastAccessedDetails",
|
|
29
|
+
"iam:SimulatePrincipalPolicy",
|
|
30
|
+
"securityhub:GetFindings",
|
|
31
|
+
"access-analyzer:Get*",
|
|
32
|
+
"access-analyzer:List*",
|
|
33
|
+
"sts:GetCallerIdentity",
|
|
34
|
+
"sts:AssumeRole",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# boto3 service names that differ from IAM action prefixes.
|
|
38
|
+
_IAM_PREFIX = {"accessanalyzer": "access-analyzer"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MutatingCallBlocked(PosturaNetError):
|
|
42
|
+
"""An AWS operation outside the read-only allowlist was attempted."""
|
|
43
|
+
|
|
44
|
+
exit_code = 10
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def iam_action(service: str, method: str) -> str:
|
|
48
|
+
"""`("ec2", "describe_network_interfaces")` -> `"ec2:DescribeNetworkInterfaces"`."""
|
|
49
|
+
prefix = _IAM_PREFIX.get(service, service)
|
|
50
|
+
op = "".join(part.capitalize() for part in method.split("_"))
|
|
51
|
+
return f"{prefix}:{op}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_allowed(action: str) -> bool:
|
|
55
|
+
return any(fnmatch.fnmatchcase(action, pattern) for pattern in ALLOWLIST)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_PAGINATOR = re.compile(r"^(get_paginator|can_paginate|get_waiter)$")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ReadOnlyClient:
|
|
62
|
+
"""Wraps a boto3 client; only allowlisted operations are reachable."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, client: Any, service: str) -> None:
|
|
65
|
+
self._client = client
|
|
66
|
+
self._service = service
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def service(self) -> str:
|
|
70
|
+
return self._service
|
|
71
|
+
|
|
72
|
+
def __getattr__(self, name: str) -> Any:
|
|
73
|
+
if name.startswith("_") or _PAGINATOR.match(name):
|
|
74
|
+
# Paginators and waiters would bypass the per-call check.
|
|
75
|
+
raise MutatingCallBlocked(f"{self._service}.{name} is not available in PosturaNet")
|
|
76
|
+
action = iam_action(self._service, name)
|
|
77
|
+
if not is_allowed(action):
|
|
78
|
+
raise MutatingCallBlocked(
|
|
79
|
+
f"{action} is not on the read-only allowlist; PosturaNet never writes to AWS"
|
|
80
|
+
)
|
|
81
|
+
return getattr(self._client, name)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def iam_policy() -> dict[str, Any]:
|
|
85
|
+
"""IAM policy document a customer attaches to PosturaNet's role."""
|
|
86
|
+
return {
|
|
87
|
+
"Version": "2012-10-17",
|
|
88
|
+
"Statement": [
|
|
89
|
+
{
|
|
90
|
+
"Sid": "PosturaNetReadOnly",
|
|
91
|
+
"Effect": "Allow",
|
|
92
|
+
"Action": sorted(ALLOWLIST),
|
|
93
|
+
"Resource": "*",
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Build read-only clients from ambient credentials or an assumed role."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
from posturanet.aws.readonly import ReadOnlyClient
|
|
8
|
+
from posturanet.errors import StageStop
|
|
9
|
+
from posturanet.models.config import AwsConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClientFactory(Protocol):
|
|
13
|
+
def __call__(self, service: str) -> ReadOnlyClient: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def boto3_factory(cfg: AwsConfig, region: str | None = None) -> ClientFactory:
|
|
17
|
+
"""Return a factory of read-only clients. Credentials are resolved lazily on first use."""
|
|
18
|
+
try:
|
|
19
|
+
import boto3
|
|
20
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
21
|
+
except ImportError as exc: # pragma: no cover - boto3 is a hard dependency
|
|
22
|
+
raise StageStop("evidence", "boto3 is not installed") from exc
|
|
23
|
+
|
|
24
|
+
session_cache: dict[str, Any] = {}
|
|
25
|
+
|
|
26
|
+
def _session() -> Any:
|
|
27
|
+
if "s" in session_cache:
|
|
28
|
+
return session_cache["s"]
|
|
29
|
+
base = boto3.session.Session(region_name=region or cfg.region)
|
|
30
|
+
if cfg.role_arn:
|
|
31
|
+
sts = ReadOnlyClient(base.client("sts"), "sts")
|
|
32
|
+
try:
|
|
33
|
+
creds = sts.assume_role(RoleArn=cfg.role_arn, RoleSessionName="posturanet")[
|
|
34
|
+
"Credentials"
|
|
35
|
+
]
|
|
36
|
+
except (BotoCoreError, ClientError) as exc:
|
|
37
|
+
raise StageStop(
|
|
38
|
+
"evidence",
|
|
39
|
+
f"could not assume {cfg.role_arn}",
|
|
40
|
+
["check the role trust policy and that this runner may assume it"],
|
|
41
|
+
why=str(exc),
|
|
42
|
+
) from exc
|
|
43
|
+
base = boto3.session.Session(
|
|
44
|
+
aws_access_key_id=creds["AccessKeyId"],
|
|
45
|
+
aws_secret_access_key=creds["SecretAccessKey"],
|
|
46
|
+
aws_session_token=creds["SessionToken"],
|
|
47
|
+
region_name=region or cfg.region,
|
|
48
|
+
)
|
|
49
|
+
session_cache["s"] = base
|
|
50
|
+
return base
|
|
51
|
+
|
|
52
|
+
def factory(service: str) -> ReadOnlyClient:
|
|
53
|
+
return ReadOnlyClient(_session().client(service), service)
|
|
54
|
+
|
|
55
|
+
return factory
|
|
File without changes
|
posturanet/cli/app.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""`posturanet` CLI. The only place that maps errors to exit codes (architecture doc 12)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Annotated, TextIO
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from posturanet import __version__
|
|
16
|
+
from posturanet.aws.readonly import iam_policy
|
|
17
|
+
from posturanet.errors import PosturaNetError, UsageError
|
|
18
|
+
from posturanet.evidence import FileSource
|
|
19
|
+
from posturanet.models import RunRecord
|
|
20
|
+
from posturanet.models.config import load_config
|
|
21
|
+
from posturanet.output import banner, console
|
|
22
|
+
from posturanet.pipeline import Context, Mode, run_fix
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from posturanet.gate import GateResult
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
name="posturanet",
|
|
29
|
+
help="PosturaNet: evidence-backed Terraform fixes for cloud security findings.",
|
|
30
|
+
add_completion=False,
|
|
31
|
+
)
|
|
32
|
+
contract_app = typer.Typer(help="Build and check behavior contracts.", no_args_is_help=True)
|
|
33
|
+
app.add_typer(contract_app, name="contract")
|
|
34
|
+
hook_app = typer.Typer(help="Editor and AI agent hooks.", no_args_is_help=True)
|
|
35
|
+
app.add_typer(hook_app, name="hook")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OutputFormat(StrEnum):
|
|
39
|
+
TEXT = "text"
|
|
40
|
+
JSON = "json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
RepoOpt = Annotated[Path, typer.Option(help="Terraform repo root.")]
|
|
44
|
+
StateOpt = Annotated[
|
|
45
|
+
Path | None, typer.Option(help="Local copy of the Terraform state (never commit it).")
|
|
46
|
+
]
|
|
47
|
+
EvidenceOpt = Annotated[
|
|
48
|
+
Path | None,
|
|
49
|
+
typer.Option("--evidence-file", help="Exported flow-log aggregate (JSON) instead of AWS."),
|
|
50
|
+
]
|
|
51
|
+
WindowOpt = Annotated[str | None, typer.Option(help="Evidence window, e.g. 90d.")]
|
|
52
|
+
FormatOpt = Annotated[OutputFormat, typer.Option("--format")]
|
|
53
|
+
AsciiOpt = Annotated[bool, typer.Option("--ascii", help="ASCII-only output.")]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _version(value: bool) -> None:
|
|
57
|
+
if value:
|
|
58
|
+
typer.echo(f"PosturaNet {__version__}")
|
|
59
|
+
raise typer.Exit(0)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _banner(stream: TextIO) -> None:
|
|
63
|
+
if banner.allowed(stream):
|
|
64
|
+
console.write(banner.render(ascii_mode=console.wants_ascii(stream)), stream)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.callback(invoke_without_command=True)
|
|
68
|
+
def _root(
|
|
69
|
+
ctx: typer.Context,
|
|
70
|
+
version: Annotated[
|
|
71
|
+
bool, typer.Option("--version", callback=_version, is_eager=True, help="Show version.")
|
|
72
|
+
] = False,
|
|
73
|
+
) -> None:
|
|
74
|
+
if ctx.invoked_subcommand is None:
|
|
75
|
+
_banner(sys.stdout)
|
|
76
|
+
typer.echo(ctx.get_help())
|
|
77
|
+
raise typer.Exit(0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _window(value: str | None) -> int | None:
|
|
81
|
+
if value is None:
|
|
82
|
+
return None
|
|
83
|
+
m = re.fullmatch(r"(\d+)d?", value.strip())
|
|
84
|
+
if not m or not 1 <= int(m.group(1)) <= 400:
|
|
85
|
+
raise UsageError(f"--window must look like 90d (1-400 days), got {value!r}")
|
|
86
|
+
return int(m.group(1))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _context(
|
|
90
|
+
mode: Mode,
|
|
91
|
+
repo: Path,
|
|
92
|
+
state: Path | None,
|
|
93
|
+
evidence_file: Path | None,
|
|
94
|
+
window: str | None,
|
|
95
|
+
) -> Context:
|
|
96
|
+
if not repo.is_dir():
|
|
97
|
+
raise UsageError(f"--repo is not a directory: {repo}")
|
|
98
|
+
if evidence_file is not None and not evidence_file.is_file():
|
|
99
|
+
raise UsageError(f"--evidence-file not found: {evidence_file}")
|
|
100
|
+
ctx = Context(
|
|
101
|
+
mode=mode,
|
|
102
|
+
state_path=state,
|
|
103
|
+
evidence=FileSource(evidence_file) if evidence_file else None,
|
|
104
|
+
window_days=_window(window),
|
|
105
|
+
)
|
|
106
|
+
if mode != "dry-run":
|
|
107
|
+
from posturanet.aws.session import boto3_factory
|
|
108
|
+
|
|
109
|
+
config, _ = load_config(repo)
|
|
110
|
+
ctx.aws = boto3_factory(config.aws)
|
|
111
|
+
return ctx
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _emit(record: RunRecord, output_format: OutputFormat, ascii_mode: bool) -> None:
|
|
115
|
+
if output_format is OutputFormat.JSON:
|
|
116
|
+
console.write(console.render_json(record))
|
|
117
|
+
else:
|
|
118
|
+
use_ascii = ascii_mode or console.wants_ascii(sys.stdout)
|
|
119
|
+
console.write(console.render_text(record, ascii_mode=use_ascii))
|
|
120
|
+
raise typer.Exit(record.exit_code)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _fail(exc: PosturaNetError) -> typer.Exit:
|
|
124
|
+
typer.echo(f"error: {exc}", err=True)
|
|
125
|
+
return typer.Exit(exc.exit_code)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command()
|
|
129
|
+
def fix(
|
|
130
|
+
target: Annotated[str, typer.Argument(help="ASFF finding file (.json) or a resource id.")],
|
|
131
|
+
dry_run: Annotated[
|
|
132
|
+
bool, typer.Option("--dry-run", help="No AWS, GitHub or terraform plan; writes nothing.")
|
|
133
|
+
] = False,
|
|
134
|
+
open_pr: Annotated[
|
|
135
|
+
bool, typer.Option("--open-pr", help="Open or update the PR when every check passes.")
|
|
136
|
+
] = False,
|
|
137
|
+
repo: RepoOpt = Path("."),
|
|
138
|
+
state: StateOpt = None,
|
|
139
|
+
evidence_file: EvidenceOpt = None,
|
|
140
|
+
window: WindowOpt = None,
|
|
141
|
+
output_format: FormatOpt = OutputFormat.TEXT,
|
|
142
|
+
ascii_mode: AsciiOpt = False,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Turn a finding into a validated fix, and optionally a PR."""
|
|
145
|
+
try:
|
|
146
|
+
if dry_run and open_pr:
|
|
147
|
+
raise UsageError("--dry-run and --open-pr can't be used together")
|
|
148
|
+
mode: Mode = "dry-run" if dry_run else ("open-pr" if open_pr else "check")
|
|
149
|
+
ctx = _context(mode, repo, state, evidence_file, window)
|
|
150
|
+
config, _ = load_config(repo)
|
|
151
|
+
record = run_fix(target, repo, config, ctx)
|
|
152
|
+
except PosturaNetError as exc:
|
|
153
|
+
raise _fail(exc) from exc
|
|
154
|
+
_emit(record, output_format, ascii_mode)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@contract_app.command("build")
|
|
158
|
+
def contract_build(
|
|
159
|
+
target: Annotated[str, typer.Argument(help="ASFF finding file or security group id.")],
|
|
160
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Don't write the contract.")] = False,
|
|
161
|
+
repo: RepoOpt = Path("."),
|
|
162
|
+
state: StateOpt = None,
|
|
163
|
+
evidence_file: EvidenceOpt = None,
|
|
164
|
+
window: WindowOpt = None,
|
|
165
|
+
output_format: FormatOpt = OutputFormat.TEXT,
|
|
166
|
+
ascii_mode: AsciiOpt = False,
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Build a draft behavior contract from observed usage."""
|
|
169
|
+
try:
|
|
170
|
+
ctx = _context("dry-run" if dry_run else "check", repo, state, evidence_file, window)
|
|
171
|
+
ctx.stop_after = "contract"
|
|
172
|
+
ctx.command = "contract build"
|
|
173
|
+
config, _ = load_config(repo)
|
|
174
|
+
record = run_fix(target, repo, config, ctx)
|
|
175
|
+
except PosturaNetError as exc:
|
|
176
|
+
raise _fail(exc) from exc
|
|
177
|
+
_emit(record, output_format, ascii_mode)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
_GATE_EXIT = {"PASS": 0, "BLOCK": 5, "UNKNOWN": 3}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _publish_check(result: GateResult, sha: str | None) -> str:
|
|
184
|
+
from posturanet.deliver.checks import publish
|
|
185
|
+
from posturanet.deliver.github import GitHub
|
|
186
|
+
|
|
187
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
188
|
+
repo_slug = os.environ.get("GITHUB_REPOSITORY")
|
|
189
|
+
head = sha or os.environ.get("GITHUB_SHA")
|
|
190
|
+
if not token or not repo_slug or not head:
|
|
191
|
+
raise UsageError(
|
|
192
|
+
"--github-check needs GITHUB_TOKEN, GITHUB_REPOSITORY and --sha (or GITHUB_SHA)"
|
|
193
|
+
)
|
|
194
|
+
return publish(GitHub(token, repo_slug), result, head)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@contract_app.command("check")
|
|
198
|
+
def contract_check(
|
|
199
|
+
plan: Annotated[
|
|
200
|
+
Path | None, typer.Option("--plan", help="`terraform show -json` output.")
|
|
201
|
+
] = None,
|
|
202
|
+
source: Annotated[
|
|
203
|
+
bool,
|
|
204
|
+
typer.Option("--source", help="Check the Terraform source instead of a plan (offline)."),
|
|
205
|
+
] = False,
|
|
206
|
+
files: Annotated[
|
|
207
|
+
list[Path] | None,
|
|
208
|
+
typer.Option("--file", help="With --source: only groups defined in this .tf file."),
|
|
209
|
+
] = None,
|
|
210
|
+
github_check: Annotated[
|
|
211
|
+
bool, typer.Option("--github-check", help="Publish the result as a GitHub check run.")
|
|
212
|
+
] = False,
|
|
213
|
+
sha: Annotated[str | None, typer.Option(help="Commit for the check run.")] = None,
|
|
214
|
+
repo: RepoOpt = Path("."),
|
|
215
|
+
output_format: FormatOpt = OutputFormat.TEXT,
|
|
216
|
+
ascii_mode: AsciiOpt = False,
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Check a change against the contracts in the repo. Exit 5 on BLOCK, 3 on UNKNOWN."""
|
|
219
|
+
from posturanet.gate import check_plan, check_source
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
if (plan is None) == (not source):
|
|
223
|
+
raise UsageError("use exactly one of --plan or --source")
|
|
224
|
+
if files and not source:
|
|
225
|
+
raise UsageError("--file only works with --source")
|
|
226
|
+
config, _ = load_config(repo)
|
|
227
|
+
if plan is not None:
|
|
228
|
+
if not plan.is_file():
|
|
229
|
+
raise UsageError(f"--plan not found: {plan}")
|
|
230
|
+
try:
|
|
231
|
+
plan_doc = json.loads(plan.read_text(encoding="utf-8"))
|
|
232
|
+
except json.JSONDecodeError as exc:
|
|
233
|
+
raise UsageError(f"--plan is not JSON (use `terraform show -json`): {exc}") from exc
|
|
234
|
+
result = check_plan(plan_doc, repo, config)
|
|
235
|
+
else:
|
|
236
|
+
result = check_source(repo, config, files or None)
|
|
237
|
+
check_url = _publish_check(result, sha) if github_check else None
|
|
238
|
+
except PosturaNetError as exc:
|
|
239
|
+
raise _fail(exc) from exc
|
|
240
|
+
|
|
241
|
+
if output_format is OutputFormat.JSON:
|
|
242
|
+
doc = result.to_dict()
|
|
243
|
+
if check_url is not None:
|
|
244
|
+
doc["check_url"] = check_url
|
|
245
|
+
console.write(json.dumps(doc, indent=2) + "\n")
|
|
246
|
+
else:
|
|
247
|
+
console.write(console.render_gate(result, ascii_mode or console.wants_ascii(sys.stdout)))
|
|
248
|
+
raise typer.Exit(_GATE_EXIT[result.status])
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@hook_app.command("claude-code")
|
|
252
|
+
def hook_claude_code(repo: RepoOpt = Path(".")) -> None:
|
|
253
|
+
"""Claude Code PostToolUse hook: check an edited .tf file against the contracts.
|
|
254
|
+
|
|
255
|
+
Reads the hook event from stdin. On BLOCK, tells Claude why so it can fix its own change.
|
|
256
|
+
Never fails the session on its own errors.
|
|
257
|
+
"""
|
|
258
|
+
from posturanet.hooks import claude_code
|
|
259
|
+
|
|
260
|
+
console.write(claude_code(sys.stdin.read(), repo))
|
|
261
|
+
raise typer.Exit(0)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@app.command()
|
|
265
|
+
def init() -> None:
|
|
266
|
+
"""Print the read-only IAM policy for PosturaNet's role. Writes nothing."""
|
|
267
|
+
_banner(sys.stderr) # stderr, so `posturanet init > policy.json` stays clean JSON
|
|
268
|
+
console.write(json.dumps(iam_policy(), indent=2) + "\n")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@app.command()
|
|
272
|
+
def mcp() -> None:
|
|
273
|
+
"""Run the read-only MCP server over stdio."""
|
|
274
|
+
try:
|
|
275
|
+
from posturanet.mcp.server import serve
|
|
276
|
+
except ImportError as exc:
|
|
277
|
+
typer.echo("error: install the MCP extra: pip install 'posturanet[mcp]'", err=True)
|
|
278
|
+
raise typer.Exit(2) from exc
|
|
279
|
+
serve()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def main() -> None:
|
|
283
|
+
app()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from posturanet.contracts.builder import build_contract, default_must_deny
|
|
2
|
+
from posturanet.contracts.store import (
|
|
3
|
+
contract_path,
|
|
4
|
+
dump_contract,
|
|
5
|
+
load_contract,
|
|
6
|
+
merge_human_fields,
|
|
7
|
+
write_contract,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"build_contract",
|
|
12
|
+
"contract_path",
|
|
13
|
+
"default_must_deny",
|
|
14
|
+
"dump_contract",
|
|
15
|
+
"load_contract",
|
|
16
|
+
"merge_human_fields",
|
|
17
|
+
"write_contract",
|
|
18
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Observations -> draft BehaviorContract (FR-14)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from posturanet import __version__
|
|
6
|
+
from posturanet.contracts.invariants import SG_SENSITIVE_PORTS, WORLD_CIDRS
|
|
7
|
+
from posturanet.evidence.periodicity import describe
|
|
8
|
+
from posturanet.models import BehaviorContract, NetRule, Observations
|
|
9
|
+
from posturanet.models.contract import ObservedMeta, ObservedWindow
|
|
10
|
+
from posturanet.synth.sg_rules import Rule
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def default_must_deny(open_rules: list[Rule], observed_ports: set[int]) -> list[NetRule]:
|
|
14
|
+
"""No world access on any sensitive port the open rules expose, nor on their single ports."""
|
|
15
|
+
ports: set[int] = set()
|
|
16
|
+
for r in open_rules:
|
|
17
|
+
if r.proto == "-1":
|
|
18
|
+
ports.update(SG_SENSITIVE_PORTS)
|
|
19
|
+
ports.update(observed_ports)
|
|
20
|
+
continue
|
|
21
|
+
if r.from_port == r.to_port and r.from_port >= 0:
|
|
22
|
+
ports.add(r.from_port)
|
|
23
|
+
ports.update(p for p in SG_SENSITIVE_PORTS if r.from_port <= p <= r.to_port)
|
|
24
|
+
ports.update(p for p in observed_ports if r.from_port <= p <= r.to_port)
|
|
25
|
+
return [NetRule(cidr=c, port=p, proto="tcp") for p in sorted(ports) for c in WORLD_CIDRS]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_contract(
|
|
29
|
+
obs: Observations,
|
|
30
|
+
open_rules: list[Rule],
|
|
31
|
+
*,
|
|
32
|
+
account: str | None,
|
|
33
|
+
region: str | None,
|
|
34
|
+
) -> BehaviorContract:
|
|
35
|
+
allow: list[NetRule] = []
|
|
36
|
+
review: list[NetRule] = []
|
|
37
|
+
for item in obs.items:
|
|
38
|
+
rule = NetRule(
|
|
39
|
+
cidr=item.cidr,
|
|
40
|
+
port=item.port,
|
|
41
|
+
proto=item.proto,
|
|
42
|
+
period=describe(item.period, item.hours_histogram),
|
|
43
|
+
count=item.count,
|
|
44
|
+
)
|
|
45
|
+
(review if item.needs_review else allow).append(rule)
|
|
46
|
+
|
|
47
|
+
return BehaviorContract(
|
|
48
|
+
resource=obs.resource_address,
|
|
49
|
+
account=account,
|
|
50
|
+
region=region,
|
|
51
|
+
observed=ObservedMeta(
|
|
52
|
+
source=obs.source,
|
|
53
|
+
window=ObservedWindow(
|
|
54
|
+
start=obs.window.start.date(), end=obs.window.end.date(), days=obs.window.days
|
|
55
|
+
),
|
|
56
|
+
confidence=obs.confidence,
|
|
57
|
+
),
|
|
58
|
+
must_allow=allow,
|
|
59
|
+
must_deny=default_must_deny(open_rules, {i.port for i in obs.items}),
|
|
60
|
+
needs_review=review,
|
|
61
|
+
unknown_risk=f"behavior rarer than once per {obs.window.days} days is not observed",
|
|
62
|
+
generated_by=f"posturanet {__version__}",
|
|
63
|
+
)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Default `must_deny` invariants per fix type (architecture doc 6.4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# Adapted from postura-backend/app/aws/remediation/ip_safe.py (sensitive-port list).
|
|
6
|
+
SG_SENSITIVE_PORTS: tuple[int, ...] = (22, 3389, 3306, 5432, 1433, 6379, 9200, 27017)
|
|
7
|
+
WORLD_CIDRS: tuple[str, ...] = ("0.0.0.0/0", "::/0")
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Read and write contract YAML in the repo (design system section 5).
|
|
2
|
+
|
|
3
|
+
Approved contracts are never overwritten. On regeneration of a draft, human-added `owner`
|
|
4
|
+
and `ref` fields on matching entries are kept.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
from ruamel.yaml import YAML
|
|
16
|
+
from ruamel.yaml.comments import CommentedMap, CommentedSeq
|
|
17
|
+
from ruamel.yaml.error import YAMLError
|
|
18
|
+
|
|
19
|
+
from posturanet.errors import StageStop
|
|
20
|
+
from posturanet.models import BehaviorContract, NetRule
|
|
21
|
+
|
|
22
|
+
HEADER = "Generated by PosturaNet. Review must_allow and needs_review, then merge to approve."
|
|
23
|
+
_KEY_ORDER = (
|
|
24
|
+
"schema",
|
|
25
|
+
"resource",
|
|
26
|
+
"account",
|
|
27
|
+
"region",
|
|
28
|
+
"kind",
|
|
29
|
+
"observed",
|
|
30
|
+
"must_allow",
|
|
31
|
+
"must_deny",
|
|
32
|
+
"needs_review",
|
|
33
|
+
"unknown_risk",
|
|
34
|
+
"status",
|
|
35
|
+
"approved_by",
|
|
36
|
+
"approved_at",
|
|
37
|
+
"generated_by",
|
|
38
|
+
)
|
|
39
|
+
_RULE_ORDER = ("cidr", "port", "proto", "period", "owner", "ref", "count")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def contract_filename(address: str) -> str:
|
|
43
|
+
return re.sub(r'[\[\]"]', "_", address) + ".yaml"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def contract_path(repo: Path, contracts_dir: str, address: str) -> Path:
|
|
47
|
+
return repo / contracts_dir / contract_filename(address)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_contract(path: Path) -> BehaviorContract | None:
|
|
51
|
+
if not path.is_file():
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
data = YAML(typ="safe").load(path.read_text(encoding="utf-8"))
|
|
55
|
+
return BehaviorContract.model_validate(data)
|
|
56
|
+
except (YAMLError, ValidationError) as exc:
|
|
57
|
+
raise StageStop(
|
|
58
|
+
"contract",
|
|
59
|
+
f"contract {path.name} is invalid",
|
|
60
|
+
["fix the YAML by hand, or delete it to regenerate a draft"],
|
|
61
|
+
why=str(exc).splitlines()[0],
|
|
62
|
+
) from exc
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def merge_human_fields(new: BehaviorContract, old: BehaviorContract | None) -> BehaviorContract:
|
|
66
|
+
if old is None:
|
|
67
|
+
return new
|
|
68
|
+
kept = {(r.cidr, r.port, r.proto): r for r in [*old.must_allow, *old.needs_review]}
|
|
69
|
+
|
|
70
|
+
def carry(rules: list[NetRule]) -> list[NetRule]:
|
|
71
|
+
out = []
|
|
72
|
+
for r in rules:
|
|
73
|
+
prev = kept.get((r.cidr, r.port, r.proto))
|
|
74
|
+
if prev is not None:
|
|
75
|
+
r = r.model_copy(update={"owner": prev.owner or r.owner, "ref": prev.ref or r.ref})
|
|
76
|
+
out.append(r)
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
return new.model_copy(
|
|
80
|
+
update={"must_allow": carry(new.must_allow), "needs_review": carry(new.needs_review)}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _flow(rule: NetRule) -> CommentedMap:
|
|
85
|
+
data = rule.model_dump(exclude_none=True)
|
|
86
|
+
m = CommentedMap((k, data[k]) for k in _RULE_ORDER if k in data)
|
|
87
|
+
m.fa.set_flow_style()
|
|
88
|
+
return m
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _seq(rules: list[NetRule]) -> CommentedSeq:
|
|
92
|
+
s = CommentedSeq(_flow(r) for r in rules)
|
|
93
|
+
if not rules:
|
|
94
|
+
s.fa.set_flow_style()
|
|
95
|
+
return s
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def dump_contract(contract: BehaviorContract) -> str:
|
|
99
|
+
data: dict[str, Any] = contract.model_dump(by_alias=True, mode="python")
|
|
100
|
+
root = CommentedMap()
|
|
101
|
+
for key in _KEY_ORDER:
|
|
102
|
+
value = data[key]
|
|
103
|
+
if key in ("must_allow", "must_deny", "needs_review"):
|
|
104
|
+
value = _seq(getattr(contract, key))
|
|
105
|
+
elif key == "observed":
|
|
106
|
+
obs = CommentedMap(
|
|
107
|
+
[
|
|
108
|
+
("source", value["source"]),
|
|
109
|
+
("window", CommentedMap(value["window"])),
|
|
110
|
+
("confidence", str(value["confidence"])),
|
|
111
|
+
]
|
|
112
|
+
)
|
|
113
|
+
obs["window"].fa.set_flow_style()
|
|
114
|
+
value = obs
|
|
115
|
+
elif key == "account" and value is not None:
|
|
116
|
+
value = str(value)
|
|
117
|
+
root[key] = value
|
|
118
|
+
root.yaml_set_start_comment(HEADER)
|
|
119
|
+
|
|
120
|
+
yaml = YAML()
|
|
121
|
+
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
122
|
+
yaml.width = 200
|
|
123
|
+
buf = io.StringIO()
|
|
124
|
+
yaml.dump(root, buf)
|
|
125
|
+
text = buf.getvalue()
|
|
126
|
+
# Quote the account id so YAML never reads it as a number.
|
|
127
|
+
if contract.account:
|
|
128
|
+
text = text.replace(f"account: {contract.account}\n", f'account: "{contract.account}"\n')
|
|
129
|
+
text = text.replace(f"account: '{contract.account}'\n", f'account: "{contract.account}"\n')
|
|
130
|
+
return text
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def write_contract(path: Path, contract: BehaviorContract) -> None:
|
|
134
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
path.write_text(dump_contract(contract), encoding="utf-8", newline="\n")
|