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.
- attestral-0.1.0/.github/workflows/ci.yml +16 -0
- attestral-0.1.0/.github/workflows/publish.yml +28 -0
- attestral-0.1.0/.gitignore +12 -0
- attestral-0.1.0/CONTRIBUTING.md +21 -0
- attestral-0.1.0/LICENSE +15 -0
- attestral-0.1.0/PKG-INFO +121 -0
- attestral-0.1.0/README.md +101 -0
- attestral-0.1.0/SECURITY.md +15 -0
- attestral-0.1.0/action.yml +26 -0
- attestral-0.1.0/attestral/__init__.py +6 -0
- attestral-0.1.0/attestral/cli.py +109 -0
- attestral-0.1.0/attestral/compile.py +100 -0
- attestral-0.1.0/attestral/drift.py +84 -0
- attestral-0.1.0/attestral/evidence.py +91 -0
- attestral-0.1.0/attestral/ingest/__init__.py +5 -0
- attestral-0.1.0/attestral/ingest/mcp.py +44 -0
- attestral-0.1.0/attestral/ingest/scan.py +20 -0
- attestral-0.1.0/attestral/ingest/terraform.py +130 -0
- attestral-0.1.0/attestral/llm.py +62 -0
- attestral-0.1.0/attestral/model.py +103 -0
- attestral-0.1.0/attestral/rules/__init__.py +3 -0
- attestral-0.1.0/attestral/rules/core_rules.yaml +93 -0
- attestral-0.1.0/attestral/rules/engine.py +101 -0
- attestral-0.1.0/examples/demo-project/main.tf +24 -0
- attestral-0.1.0/examples/demo-project/mcp.json +33 -0
- attestral-0.1.0/examples/demo-project/runtime-events.jsonl +4 -0
- attestral-0.1.0/examples/github-actions/drift-schedule.yml +35 -0
- attestral-0.1.0/integrations/mcp-guard/PR_DESCRIPTION.md +32 -0
- attestral-0.1.0/integrations/mcp-guard/README.md +15 -0
- attestral-0.1.0/integrations/mcp-guard/attestral_telemetry.py +64 -0
- attestral-0.1.0/pyproject.toml +25 -0
- attestral-0.1.0/tests/test_compile.py +42 -0
- attestral-0.1.0/tests/test_drift.py +36 -0
- attestral-0.1.0/tests/test_evidence.py +23 -0
- attestral-0.1.0/tests/test_rules.py +35 -0
- attestral-0.1.0/tests/test_telemetry_integration.py +46 -0
- attestral-0.1.0/tests/test_terraform_parsers.py +32 -0
- attestral-0.1.0/website/docs.html +210 -0
- 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,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
|
+
```
|
attestral-0.1.0/LICENSE
ADDED
|
@@ -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.
|
attestral-0.1.0/PKG-INFO
ADDED
|
@@ -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,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
|