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.
Files changed (67) hide show
  1. posturanet/__init__.py +3 -0
  2. posturanet/__main__.py +3 -0
  3. posturanet/aws/__init__.py +3 -0
  4. posturanet/aws/readonly.py +96 -0
  5. posturanet/aws/session.py +55 -0
  6. posturanet/cli/__init__.py +0 -0
  7. posturanet/cli/app.py +283 -0
  8. posturanet/contracts/__init__.py +18 -0
  9. posturanet/contracts/builder.py +63 -0
  10. posturanet/contracts/invariants.py +7 -0
  11. posturanet/contracts/store.py +135 -0
  12. posturanet/deliver/__init__.py +4 -0
  13. posturanet/deliver/checks.py +98 -0
  14. posturanet/deliver/github.py +231 -0
  15. posturanet/deliver/idempotency.py +49 -0
  16. posturanet/deliver/pr_body.py +218 -0
  17. posturanet/errors.py +39 -0
  18. posturanet/evidence/__init__.py +25 -0
  19. posturanet/evidence/confidence.py +48 -0
  20. posturanet/evidence/flowlogs.py +296 -0
  21. posturanet/evidence/periodicity.py +41 -0
  22. posturanet/gate.py +243 -0
  23. posturanet/hooks.py +85 -0
  24. posturanet/ingest/__init__.py +20 -0
  25. posturanet/ingest/asff.py +132 -0
  26. posturanet/ingest/controls.json +17 -0
  27. posturanet/ingest/controls.py +17 -0
  28. posturanet/ingest/manual.py +39 -0
  29. posturanet/locate/__init__.py +145 -0
  30. posturanet/locate/backend.py +67 -0
  31. posturanet/locate/codeowners.py +54 -0
  32. posturanet/locate/hcl_index.py +103 -0
  33. posturanet/locate/tfstate.py +94 -0
  34. posturanet/mcp/__init__.py +0 -0
  35. posturanet/mcp/server.py +119 -0
  36. posturanet/models/__init__.py +41 -0
  37. posturanet/models/common.py +25 -0
  38. posturanet/models/config.py +104 -0
  39. posturanet/models/contract.py +59 -0
  40. posturanet/models/finding.py +40 -0
  41. posturanet/models/location.py +23 -0
  42. posturanet/models/observations.py +56 -0
  43. posturanet/models/run.py +55 -0
  44. posturanet/models/validation.py +66 -0
  45. posturanet/output/__init__.py +0 -0
  46. posturanet/output/banner.py +98 -0
  47. posturanet/output/console.py +77 -0
  48. posturanet/output/symbols.py +29 -0
  49. posturanet/pipeline.py +510 -0
  50. posturanet/proposal.py +53 -0
  51. posturanet/py.typed +0 -0
  52. posturanet/synth/__init__.py +13 -0
  53. posturanet/synth/hcl_render.py +160 -0
  54. posturanet/synth/sg_rules.py +300 -0
  55. posturanet/synth/sg_z3.py +157 -0
  56. posturanet/validate/__init__.py +4 -0
  57. posturanet/validate/guardrails.py +33 -0
  58. posturanet/validate/invariants.py +112 -0
  59. posturanet/validate/plan_scope.py +71 -0
  60. posturanet/validate/recheck.py +71 -0
  61. posturanet/validate/runner.py +273 -0
  62. posturanet/validate/tf.py +92 -0
  63. posturanet-0.1.0.dist-info/METADATA +85 -0
  64. posturanet-0.1.0.dist-info/RECORD +67 -0
  65. posturanet-0.1.0.dist-info/WHEEL +4 -0
  66. posturanet-0.1.0.dist-info/entry_points.txt +2 -0
  67. posturanet-0.1.0.dist-info/licenses/LICENSE.md +105 -0
posturanet/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """PosturaNet: evidence-backed remediation agent for AWS + Terraform."""
2
+
3
+ __version__ = "0.1.0"
posturanet/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from posturanet.cli.app import main
2
+
3
+ main()
@@ -0,0 +1,3 @@
1
+ from posturanet.aws.readonly import ALLOWLIST, MutatingCallBlocked, ReadOnlyClient, iam_policy
2
+
3
+ __all__ = ["ALLOWLIST", "MutatingCallBlocked", "ReadOnlyClient", "iam_policy"]
@@ -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")
@@ -0,0 +1,4 @@
1
+ from posturanet.deliver.github import Delivery, GitHub, branch_name, deliver
2
+ from posturanet.deliver.idempotency import idempotency_key
3
+
4
+ __all__ = ["Delivery", "GitHub", "branch_name", "deliver", "idempotency_key"]