attestral 0.1.0__tar.gz

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 (39) hide show
  1. attestral-0.1.0/.github/workflows/ci.yml +16 -0
  2. attestral-0.1.0/.github/workflows/publish.yml +28 -0
  3. attestral-0.1.0/.gitignore +12 -0
  4. attestral-0.1.0/CONTRIBUTING.md +21 -0
  5. attestral-0.1.0/LICENSE +15 -0
  6. attestral-0.1.0/PKG-INFO +121 -0
  7. attestral-0.1.0/README.md +101 -0
  8. attestral-0.1.0/SECURITY.md +15 -0
  9. attestral-0.1.0/action.yml +26 -0
  10. attestral-0.1.0/attestral/__init__.py +6 -0
  11. attestral-0.1.0/attestral/cli.py +109 -0
  12. attestral-0.1.0/attestral/compile.py +100 -0
  13. attestral-0.1.0/attestral/drift.py +84 -0
  14. attestral-0.1.0/attestral/evidence.py +91 -0
  15. attestral-0.1.0/attestral/ingest/__init__.py +5 -0
  16. attestral-0.1.0/attestral/ingest/mcp.py +44 -0
  17. attestral-0.1.0/attestral/ingest/scan.py +20 -0
  18. attestral-0.1.0/attestral/ingest/terraform.py +130 -0
  19. attestral-0.1.0/attestral/llm.py +62 -0
  20. attestral-0.1.0/attestral/model.py +103 -0
  21. attestral-0.1.0/attestral/rules/__init__.py +3 -0
  22. attestral-0.1.0/attestral/rules/core_rules.yaml +93 -0
  23. attestral-0.1.0/attestral/rules/engine.py +101 -0
  24. attestral-0.1.0/examples/demo-project/main.tf +24 -0
  25. attestral-0.1.0/examples/demo-project/mcp.json +33 -0
  26. attestral-0.1.0/examples/demo-project/runtime-events.jsonl +4 -0
  27. attestral-0.1.0/examples/github-actions/drift-schedule.yml +35 -0
  28. attestral-0.1.0/integrations/mcp-guard/PR_DESCRIPTION.md +32 -0
  29. attestral-0.1.0/integrations/mcp-guard/README.md +15 -0
  30. attestral-0.1.0/integrations/mcp-guard/attestral_telemetry.py +64 -0
  31. attestral-0.1.0/pyproject.toml +25 -0
  32. attestral-0.1.0/tests/test_compile.py +42 -0
  33. attestral-0.1.0/tests/test_drift.py +36 -0
  34. attestral-0.1.0/tests/test_evidence.py +23 -0
  35. attestral-0.1.0/tests/test_rules.py +35 -0
  36. attestral-0.1.0/tests/test_telemetry_integration.py +46 -0
  37. attestral-0.1.0/tests/test_terraform_parsers.py +32 -0
  38. attestral-0.1.0/website/docs.html +210 -0
  39. attestral-0.1.0/website/index.html +257 -0
@@ -0,0 +1,16 @@
1
+ name: ci
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix:
8
+ python-version: ["3.10", "3.12"]
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: actions/setup-python@v5
12
+ with:
13
+ python-version: ${{ matrix.python-version }}
14
+ - run: pip install -e ".[dev,terraform]"
15
+ - run: ruff check attestral tests
16
+ - run: pytest -q
@@ -0,0 +1,28 @@
1
+ name: publish
2
+ on:
3
+ release:
4
+ types: [published]
5
+ workflow_dispatch: {}
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+ environment:
14
+ name: pypi
15
+ url: https://pypi.org/project/attestral/
16
+ permissions:
17
+ id-token: write # REQUIRED for PyPI Trusted Publishing (OIDC)
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.12"
23
+ - name: Build sdist + wheel
24
+ run: |
25
+ python -m pip install --upgrade build
26
+ python -m build
27
+ - name: Publish to PyPI
28
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .ruff_cache/
8
+ .pytest_cache/
9
+ .DS_Store
10
+ .vercel
11
+ report.json
12
+ .claude/
@@ -0,0 +1,21 @@
1
+ # Contributing to Attestral
2
+
3
+ Thanks for considering a contribution.
4
+
5
+ ## Ground rules
6
+ - Open an issue before large changes; small fixes can go straight to PR.
7
+ - Every PR needs tests. `pytest -q` and `ruff check attestral tests` must pass.
8
+ - New detection rules go in YAML with structured matchers (see docs) - no
9
+ executable logic in rule files, ever. Unknown matchers fail closed by design.
10
+ - Contributions are accepted under the project CLA (one click on your first PR).
11
+
12
+ ## Scope
13
+ This repo is the complete open-source core: model, ingesters, rules, evidence
14
+ chain, compiler, drift detection, telemetry, Action. Hosted/team features are
15
+ developed elsewhere and are out of scope here.
16
+
17
+ ## Development
18
+ ```bash
19
+ pip install -e ".[dev,terraform]"
20
+ pytest -q
21
+ ```
@@ -0,0 +1,15 @@
1
+ Apache License 2.0
2
+
3
+ Copyright 2026 Attestral
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: attestral
3
+ Version: 0.1.0
4
+ Summary: Continuous, audit-ready security design review for cloud and agentic systems
5
+ Author: Attestral
6
+ License: Apache-2.0
7
+ License-File: LICENSE
8
+ Keywords: design-review,mcp,security,terraform,threat-modeling
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: click>=8.1
11
+ Requires-Dist: pyyaml>=6.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
14
+ Requires-Dist: ruff>=0.6; extra == 'dev'
15
+ Provides-Extra: llm
16
+ Requires-Dist: anthropic>=0.40; extra == 'llm'
17
+ Provides-Extra: terraform
18
+ Requires-Dist: python-hcl2>=4.3; extra == 'terraform'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Attestral
22
+
23
+ **Continuous, audit-ready security design review for cloud and agentic systems.**
24
+
25
+ Attestral ingests your infrastructure-as-code and agent/MCP configurations, builds a unified system model, runs a deterministic rule pack (plus optional LLM threat elicitation), and produces a design review with a **tamper-evident evidence chain** you can hand to reviewers, auditors, and customers.
26
+
27
+ ```
28
+ pip install attestral
29
+ attestral scan ./my-project
30
+ ```
31
+
32
+ ## Why
33
+
34
+ - Legacy threat modeling platforms are questionnaire-driven, consultant-heavy, and priced for the Fortune 500. In practice most teams do this work in a spreadsheet.
35
+ - The fastest-growing attack surface (AI agents, MCP servers, tool permissions) is the one legacy tools understand least.
36
+ - Review output is only worth what you can prove. Every Attestral run emits a SHA-256 hash chain over its findings, and altering any past entry invalidates the chain head.
37
+
38
+ ## What it does today (v0.1)
39
+
40
+ | Layer | Status |
41
+ |---|---|
42
+ | Terraform ingestion (design-level) | ✅ dependency-free scanner |
43
+ | MCP server config ingestion | ✅ `mcp.json` / `claude_desktop_config.json` |
44
+ | Deterministic rule pack | ✅ 10 rules: cloud misconfig + agentic tool risk |
45
+ | Framework mapping | ✅ NIST 800-53, ASVS, SOC 2, OWASP Agentic refs per finding |
46
+ | Evidence chain + verification | ✅ `attestral verify report.json` |
47
+ | Fail-closed CI gate | ✅ `--fail-on high` |
48
+ | LLM threat elicitation | ✅ optional, `--llm` + `ANTHROPIC_API_KEY`, findings tagged separately |
49
+ | Design→policy compiler (`attestral compile`) | ✅ mcp-guard default-deny policy, bound to the review chain head |
50
+ | Design-runtime drift detection (`attestral drift`) | ✅ JSONL telemetry diffed against the attested design |
51
+ | PR design-diff (GitHub App) | 🔜 roadmap |
52
+ | IriusRisk/ThreatModeler import | 🔜 roadmap |
53
+
54
+ ## Usage
55
+
56
+ ```bash
57
+ # Scan a project (Terraform + MCP configs discovered automatically)
58
+ attestral scan ./my-project
59
+
60
+ # Markdown + JSON evidence report
61
+ attestral scan ./my-project -o review --format both
62
+
63
+ # CI gate: fail the pipeline on high/critical design findings
64
+ attestral scan . --fail-on high
65
+
66
+ # Add LLM design-review reasoning on top of the deterministic layer
67
+ export ANTHROPIC_API_KEY=...
68
+ attestral scan ./my-project --llm
69
+
70
+ # Prove a report hasn't been altered
71
+ attestral verify review.json
72
+
73
+ # Close the loop: compile the attested design into a runtime policy...
74
+ attestral compile ./my-project -o policy.yaml
75
+ # ...and diff runtime telemetry (mcp-guard JSONL) against it
76
+ attestral drift policy.yaml events.jsonl --fail-on-drift
77
+ ```
78
+
79
+ Try it on the included demo:
80
+
81
+ ```bash
82
+ attestral scan examples/demo-project
83
+ ```
84
+
85
+ ## Architecture
86
+
87
+ 1. **Ingest** → unified `SystemModel` (components, edges, trust boundaries) from Terraform and MCP configs.
88
+ 2. **Deterministic layer** → typed matchers in YAML rules (`attestral/rules/core_rules.yaml`). No `eval`, unknown matchers fail closed.
89
+ 3. **LLM layer (optional)** → design-level threat elicitation over the model JSON; findings tagged `origin: llm`, never silently mixed with deterministic results.
90
+ 4. **Evidence layer** → hash-chained findings, markdown/JSON export, offline verification.
91
+
92
+ ## Writing custom rules
93
+
94
+ ```yaml
95
+ rules:
96
+ - id: ORG-001
97
+ title: Internal service exposed without auth attribute
98
+ severity: high
99
+ target: aws_lb
100
+ match: { attr_missing: auth }
101
+ description: ...
102
+ recommendation: ...
103
+ frameworks: ["NIST AC-3"]
104
+ ```
105
+
106
+ ```bash
107
+ attestral scan . # core pack
108
+ python -c "from attestral.rules import RuleEngine; RuleEngine(['org_rules.yaml'])"
109
+ ```
110
+
111
+ ## Development
112
+
113
+ ```bash
114
+ pip install -e ".[dev]"
115
+ pytest -q
116
+ ruff check attestral tests
117
+ ```
118
+
119
+ ## License
120
+
121
+ Apache 2.0
@@ -0,0 +1,101 @@
1
+ # Attestral
2
+
3
+ **Continuous, audit-ready security design review for cloud and agentic systems.**
4
+
5
+ Attestral ingests your infrastructure-as-code and agent/MCP configurations, builds a unified system model, runs a deterministic rule pack (plus optional LLM threat elicitation), and produces a design review with a **tamper-evident evidence chain** you can hand to reviewers, auditors, and customers.
6
+
7
+ ```
8
+ pip install attestral
9
+ attestral scan ./my-project
10
+ ```
11
+
12
+ ## Why
13
+
14
+ - Legacy threat modeling platforms are questionnaire-driven, consultant-heavy, and priced for the Fortune 500. In practice most teams do this work in a spreadsheet.
15
+ - The fastest-growing attack surface (AI agents, MCP servers, tool permissions) is the one legacy tools understand least.
16
+ - Review output is only worth what you can prove. Every Attestral run emits a SHA-256 hash chain over its findings, and altering any past entry invalidates the chain head.
17
+
18
+ ## What it does today (v0.1)
19
+
20
+ | Layer | Status |
21
+ |---|---|
22
+ | Terraform ingestion (design-level) | ✅ dependency-free scanner |
23
+ | MCP server config ingestion | ✅ `mcp.json` / `claude_desktop_config.json` |
24
+ | Deterministic rule pack | ✅ 10 rules: cloud misconfig + agentic tool risk |
25
+ | Framework mapping | ✅ NIST 800-53, ASVS, SOC 2, OWASP Agentic refs per finding |
26
+ | Evidence chain + verification | ✅ `attestral verify report.json` |
27
+ | Fail-closed CI gate | ✅ `--fail-on high` |
28
+ | LLM threat elicitation | ✅ optional, `--llm` + `ANTHROPIC_API_KEY`, findings tagged separately |
29
+ | Design→policy compiler (`attestral compile`) | ✅ mcp-guard default-deny policy, bound to the review chain head |
30
+ | Design-runtime drift detection (`attestral drift`) | ✅ JSONL telemetry diffed against the attested design |
31
+ | PR design-diff (GitHub App) | 🔜 roadmap |
32
+ | IriusRisk/ThreatModeler import | 🔜 roadmap |
33
+
34
+ ## Usage
35
+
36
+ ```bash
37
+ # Scan a project (Terraform + MCP configs discovered automatically)
38
+ attestral scan ./my-project
39
+
40
+ # Markdown + JSON evidence report
41
+ attestral scan ./my-project -o review --format both
42
+
43
+ # CI gate: fail the pipeline on high/critical design findings
44
+ attestral scan . --fail-on high
45
+
46
+ # Add LLM design-review reasoning on top of the deterministic layer
47
+ export ANTHROPIC_API_KEY=...
48
+ attestral scan ./my-project --llm
49
+
50
+ # Prove a report hasn't been altered
51
+ attestral verify review.json
52
+
53
+ # Close the loop: compile the attested design into a runtime policy...
54
+ attestral compile ./my-project -o policy.yaml
55
+ # ...and diff runtime telemetry (mcp-guard JSONL) against it
56
+ attestral drift policy.yaml events.jsonl --fail-on-drift
57
+ ```
58
+
59
+ Try it on the included demo:
60
+
61
+ ```bash
62
+ attestral scan examples/demo-project
63
+ ```
64
+
65
+ ## Architecture
66
+
67
+ 1. **Ingest** → unified `SystemModel` (components, edges, trust boundaries) from Terraform and MCP configs.
68
+ 2. **Deterministic layer** → typed matchers in YAML rules (`attestral/rules/core_rules.yaml`). No `eval`, unknown matchers fail closed.
69
+ 3. **LLM layer (optional)** → design-level threat elicitation over the model JSON; findings tagged `origin: llm`, never silently mixed with deterministic results.
70
+ 4. **Evidence layer** → hash-chained findings, markdown/JSON export, offline verification.
71
+
72
+ ## Writing custom rules
73
+
74
+ ```yaml
75
+ rules:
76
+ - id: ORG-001
77
+ title: Internal service exposed without auth attribute
78
+ severity: high
79
+ target: aws_lb
80
+ match: { attr_missing: auth }
81
+ description: ...
82
+ recommendation: ...
83
+ frameworks: ["NIST AC-3"]
84
+ ```
85
+
86
+ ```bash
87
+ attestral scan . # core pack
88
+ python -c "from attestral.rules import RuleEngine; RuleEngine(['org_rules.yaml'])"
89
+ ```
90
+
91
+ ## Development
92
+
93
+ ```bash
94
+ pip install -e ".[dev]"
95
+ pytest -q
96
+ ruff check attestral tests
97
+ ```
98
+
99
+ ## License
100
+
101
+ Apache 2.0
@@ -0,0 +1,15 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a vulnerability
4
+ Email security@attestral.dev. You'll get an acknowledgment within 48 hours
5
+ and a fix-or-mitigation plan within 7 days for confirmed issues. Please do
6
+ not open public issues for vulnerabilities.
7
+
8
+ ## Scope
9
+ The `attestral` package and this repository's GitHub Action. Include a
10
+ reproduction; the demo project in `examples/` is a good base.
11
+
12
+ ## Design commitments
13
+ - Fail-closed: unknown rule matchers never match; compilation defaults to deny.
14
+ - No `eval`/`exec` anywhere in the rule path.
15
+ - The evidence chain is deterministic and verifiable offline.
@@ -0,0 +1,26 @@
1
+ name: 'Attestral Drift Check'
2
+ description: 'Prove your deployment still matches its attested security design review'
3
+ author: 'Attestral'
4
+ branding:
5
+ icon: 'shield'
6
+ color: 'red'
7
+ inputs:
8
+ policy:
9
+ description: 'Path to the compiled mcp-guard policy (from `attestral compile`)'
10
+ required: true
11
+ events:
12
+ description: 'Path to runtime telemetry JSONL (mcp-guard format)'
13
+ required: true
14
+ version:
15
+ description: 'attestral version to install'
16
+ required: false
17
+ default: 'attestral'
18
+ runs:
19
+ using: 'composite'
20
+ steps:
21
+ - name: Install attestral
22
+ shell: bash
23
+ run: pip install --quiet "${{ inputs.version }}"
24
+ - name: Check design-runtime drift (fail-closed)
25
+ shell: bash
26
+ run: attestral drift "${{ inputs.policy }}" "${{ inputs.events }}" --fail-on-drift
@@ -0,0 +1,6 @@
1
+ """Attestral: continuous, audit-ready security design review."""
2
+ __version__ = "0.1.0"
3
+
4
+ from attestral.model import Component, Edge, Finding, SystemModel, TrustBoundary
5
+
6
+ __all__ = ["SystemModel", "Component", "Edge", "TrustBoundary", "Finding"]
@@ -0,0 +1,109 @@
1
+ """Attestral CLI: scan a project, emit an audit-ready design review."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from attestral import __version__
11
+ from attestral.evidence import audit_chain, render_markdown, verify_chain
12
+ from attestral.ingest import build_model
13
+ from attestral.rules import RuleEngine
14
+
15
+
16
+ @click.group()
17
+ @click.version_option(__version__)
18
+ def main() -> None:
19
+ """Attestral - continuous, audit-ready security design review."""
20
+
21
+
22
+ @main.command()
23
+ @click.argument("path", type=click.Path(exists=True))
24
+ @click.option("-o", "--output", default="attestral-report", help="Output file stem.")
25
+ @click.option("--format", "fmt", type=click.Choice(["md", "json", "both"]), default="both")
26
+ @click.option("--llm", is_flag=True, help="Add LLM threat elicitation (needs ANTHROPIC_API_KEY).")
27
+ @click.option("--fail-on", type=click.Choice(["critical", "high", "medium", "low"]), default=None,
28
+ help="Exit non-zero if findings at/above this severity exist (CI gate).")
29
+ def scan(path: str, output: str, fmt: str, llm: bool, fail_on: str | None) -> None:
30
+ """Scan PATH (Terraform, MCP configs) and generate a design review."""
31
+ model = build_model(path)
32
+ findings = RuleEngine().evaluate(model)
33
+ if llm:
34
+ from attestral.llm import elicit
35
+ findings += elicit(model)
36
+
37
+ if fmt in ("md", "both"):
38
+ Path(f"{output}.md").write_text(render_markdown(model, findings, path))
39
+ click.echo(f"wrote {output}.md")
40
+ if fmt in ("json", "both"):
41
+ Path(f"{output}.json").write_text(
42
+ json.dumps({"target": path, "chain": audit_chain(findings)}, indent=2)
43
+ )
44
+ click.echo(f"wrote {output}.json")
45
+
46
+ for f in findings:
47
+ click.echo(f" [{f.severity.value.upper():8}] {f.rule_id} {f.title} ({f.component_id})")
48
+ click.echo(f"{len(model.components)} components · {len(findings)} findings")
49
+
50
+ if fail_on:
51
+ from attestral.model import Severity
52
+ threshold = Severity(fail_on).rank
53
+ if any(f.severity.rank >= threshold for f in findings):
54
+ click.echo(f"FAIL-CLOSED: findings at or above '{fail_on}'", err=True)
55
+ sys.exit(1)
56
+
57
+
58
+ @main.command()
59
+ @click.argument("report", type=click.Path(exists=True))
60
+ def verify(report: str) -> None:
61
+ """Verify the tamper-evident audit chain in a JSON report."""
62
+ data = json.loads(Path(report).read_text())
63
+ ok = verify_chain(data.get("chain", []))
64
+ click.echo("chain VALID ✅" if ok else "chain INVALID - report has been altered ❌")
65
+ sys.exit(0 if ok else 1)
66
+
67
+
68
+ @main.command()
69
+ @click.argument("path", type=click.Path(exists=True))
70
+ @click.option("-o", "--output", default="mcp-guard-policy.yaml", help="Policy output file.")
71
+ def compile(path: str, output: str) -> None:
72
+ """Compile PATH's attested design into an mcp-guard runtime policy."""
73
+ from attestral.compile import compile_policy, render_policy_yaml
74
+ model = build_model(path)
75
+ findings = RuleEngine().evaluate(model)
76
+ chain = audit_chain(findings)
77
+ head = chain[-1]["hash"] if chain else ""
78
+ policy = compile_policy(model, findings, chain_head=head)
79
+ Path(output).write_text(render_policy_yaml(policy))
80
+ allowed = sum(1 for s in policy["servers"].values() if s["allow"])
81
+ denied = len(policy["servers"]) - allowed
82
+ click.echo(f"wrote {output} · default deny · {allowed} allowed, {denied} denied")
83
+ for name, s in policy["servers"].items():
84
+ mark = "ALLOW" if s["allow"] else "DENY "
85
+ why = "" if s["allow"] else f" ({s.get('reason','')})"
86
+ click.echo(f" [{mark}] {name}{why}")
87
+
88
+
89
+ @main.command()
90
+ @click.argument("policy_file", type=click.Path(exists=True))
91
+ @click.argument("events_file", type=click.Path(exists=True))
92
+ @click.option("--fail-on-drift", is_flag=True, help="Exit non-zero on any drift (CI/cron gate).")
93
+ def drift(policy_file: str, events_file: str, fail_on_drift: bool) -> None:
94
+ """Diff runtime EVENTS_FILE (JSONL) against a compiled POLICY_FILE."""
95
+ import yaml as _yaml
96
+ from attestral.drift import detect_drift, load_events
97
+ policy = _yaml.safe_load(Path(policy_file).read_text())
98
+ events = load_events(events_file)
99
+ findings = detect_drift(policy, events)
100
+ for f in findings:
101
+ click.echo(f" [{f.severity.value.upper():8}] {f.rule_id} {f.title} ({f.component_id})")
102
+ click.echo(f"{len(events)} events · {len(findings)} drift findings")
103
+ if findings and fail_on_drift:
104
+ click.echo("DRIFT: deployment no longer matches the attested design", err=True)
105
+ sys.exit(1)
106
+
107
+
108
+ if __name__ == "__main__":
109
+ main()
@@ -0,0 +1,100 @@
1
+ """Compile an attested design into a runtime policy.
2
+
3
+ This is the loop-closer: the reviewed system model (plus its findings)
4
+ becomes an mcp-guard-compatible policy document. The threat model is not a
5
+ PDF - it is the runtime configuration.
6
+
7
+ Compilation rules (fail-closed):
8
+ - default: deny - servers absent from the attested model are never allowed
9
+ - critical findings against a server deny it outright (with the rule id as reason)
10
+ - filesystem scopes are narrowed to the attested roots; broad roots (ATL-102)
11
+ compile to `allow: false` until re-scoped in the design
12
+ - non-TLS transports (ATL-101) compile to a tls_only constraint violation → deny
13
+ - env-secret findings (ATL-104) compile to a `forbid_env_secrets` constraint
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import datetime as _dt
18
+ import hashlib
19
+
20
+ import yaml
21
+
22
+ from attestral.model import Finding, SystemModel
23
+
24
+ POLICY_VERSION = 1
25
+ _BROAD_ROOTS = {"/", "~", "/home", "/Users"}
26
+
27
+
28
+ def _model_hash(model: SystemModel) -> str:
29
+ return hashlib.sha256(model.to_json().encode()).hexdigest()
30
+
31
+
32
+ def compile_policy(
33
+ model: SystemModel,
34
+ findings: list[Finding],
35
+ chain_head: str = "",
36
+ ) -> dict:
37
+ """Return an mcp-guard v0 policy dict derived from the attested design."""
38
+ by_component: dict[str, list[Finding]] = {}
39
+ for f in findings:
40
+ by_component.setdefault(f.component_id, []).append(f)
41
+
42
+ servers: dict[str, dict] = {}
43
+ for c in model.by_type("mcp_server"):
44
+ entry: dict = {"allow": True, "constraints": {}, "attested_source": c.source}
45
+ server_findings = by_component.get(c.id, [])
46
+ deny_reasons = [
47
+ f.rule_id for f in server_findings if f.severity.value == "critical"
48
+ ]
49
+
50
+ # Transport: attested design must be TLS; http:// compiles to deny.
51
+ url = str(c.attr("url", ""))
52
+ if url:
53
+ entry["constraints"]["transport"] = "tls_only"
54
+ if url.startswith("http://"):
55
+ deny_reasons.append("ATL-101")
56
+
57
+ # Filesystem scope: allow only attested, non-broad roots.
58
+ args = [str(a) for a in (c.attr("args") or [])]
59
+ roots = [a for a in args if a.startswith(("/", "~"))]
60
+ if roots:
61
+ narrow = [r for r in roots if r not in _BROAD_ROOTS]
62
+ if narrow:
63
+ entry["constraints"]["root_paths"] = sorted(narrow)
64
+ else:
65
+ deny_reasons.append("ATL-102")
66
+
67
+ # Secrets in env: enforce at the proxy.
68
+ if c.attr("_env_has_secrets"):
69
+ entry["constraints"]["forbid_env_secrets"] = True
70
+
71
+ if deny_reasons:
72
+ entry["allow"] = False
73
+ entry["reason"] = (
74
+ "denied by attested design review: " + ", ".join(sorted(set(deny_reasons)))
75
+ )
76
+ entry.pop("constraints", None)
77
+
78
+ servers[c.name] = entry
79
+
80
+ return {
81
+ "version": POLICY_VERSION,
82
+ "metadata": {
83
+ "generated_by": "attestral",
84
+ "generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"),
85
+ "model_hash": _model_hash(model),
86
+ "review_chain_head": chain_head,
87
+ },
88
+ "default": "deny",
89
+ "servers": servers,
90
+ }
91
+
92
+
93
+ def render_policy_yaml(policy: dict) -> str:
94
+ header = (
95
+ "# mcp-guard policy - COMPILED FROM AN ATTESTED DESIGN REVIEW.\n"
96
+ "# Do not hand-edit: change the design, re-review, re-compile.\n"
97
+ f"# model_hash: {policy['metadata']['model_hash'][:16]}… "
98
+ f"chain_head: {(policy['metadata']['review_chain_head'] or '-')[:16]}\n"
99
+ )
100
+ return header + yaml.safe_dump(policy, sort_keys=False)
@@ -0,0 +1,84 @@
1
+ """Design-runtime drift detection.
2
+
3
+ Reads a JSONL stream of tool-call events (mcp-guard telemetry format:
4
+ one object per line with at least `server`, `tool`, and optionally
5
+ `args`, `url`, `ts`) and diffs each event against the compiled policy
6
+ derived from the attested design.
7
+
8
+ Fail-closed philosophy carries through: an event that references a server
9
+ absent from the attested model is CRITICAL drift - the deployed system has
10
+ grown beyond what was reviewed.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+ from attestral.model import Finding, Severity
18
+
19
+ DRIFT_RULES = {
20
+ "DRF-001": ("Unattested server observed at runtime", Severity.CRITICAL),
21
+ "DRF-002": ("Denied server invoked at runtime", Severity.CRITICAL),
22
+ "DRF-003": ("Filesystem access outside attested roots", Severity.HIGH),
23
+ "DRF-004": ("Non-TLS transport observed for TLS-constrained server", Severity.HIGH),
24
+ }
25
+
26
+
27
+ def _mk(rule: str, server: str, detail: str, event_no: int) -> Finding:
28
+ title, sev = DRIFT_RULES[rule]
29
+ return Finding(
30
+ rule_id=rule,
31
+ title=title,
32
+ severity=sev,
33
+ component_id=f"mcp_server.{server}",
34
+ description=f"Event #{event_no}: {detail}",
35
+ recommendation=(
36
+ "Either revert the runtime change, or update the design, re-run the "
37
+ "review, and re-compile the policy so deployment and review match."
38
+ ),
39
+ source="runtime-telemetry",
40
+ origin="deterministic",
41
+ )
42
+
43
+
44
+ def _path_in_roots(path: str, roots: list[str]) -> bool:
45
+ return any(path == r or path.startswith(r.rstrip("/") + "/") for r in roots)
46
+
47
+
48
+ def detect_drift(policy: dict, events: list[dict]) -> list[Finding]:
49
+ servers: dict[str, dict] = policy.get("servers", {})
50
+ findings: list[Finding] = []
51
+ for i, ev in enumerate(events, 1):
52
+ name = str(ev.get("server", ""))
53
+ entry = servers.get(name)
54
+
55
+ if entry is None:
56
+ findings.append(_mk("DRF-001", name, f"server '{name}' is not in the attested design", i))
57
+ continue
58
+ if not entry.get("allow", False):
59
+ findings.append(_mk("DRF-002", name, entry.get("reason", "denied by policy"), i))
60
+ continue
61
+
62
+ constraints = entry.get("constraints", {})
63
+ roots = constraints.get("root_paths")
64
+ if roots:
65
+ for arg in [str(a) for a in ev.get("args", []) if str(a).startswith(("/", "~"))]:
66
+ if not _path_in_roots(arg, roots):
67
+ findings.append(_mk("DRF-003", name, f"path '{arg}' outside attested roots {roots}", i))
68
+ if constraints.get("transport") == "tls_only" and str(ev.get("url", "")).startswith("http://"):
69
+ findings.append(_mk("DRF-004", name, f"plaintext url '{ev.get('url')}'", i))
70
+ findings.sort(key=lambda f: f.severity.rank, reverse=True)
71
+ return findings
72
+
73
+
74
+ def load_events(path: str | Path) -> list[dict]:
75
+ events = []
76
+ for line in Path(path).read_text().splitlines():
77
+ line = line.strip()
78
+ if not line:
79
+ continue
80
+ try:
81
+ events.append(json.loads(line))
82
+ except json.JSONDecodeError:
83
+ continue
84
+ return events