attestral 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.
- attestral/__init__.py +6 -0
- attestral/cli.py +109 -0
- attestral/compile.py +100 -0
- attestral/drift.py +84 -0
- attestral/evidence.py +91 -0
- attestral/ingest/__init__.py +5 -0
- attestral/ingest/mcp.py +44 -0
- attestral/ingest/scan.py +20 -0
- attestral/ingest/terraform.py +130 -0
- attestral/llm.py +62 -0
- attestral/model.py +103 -0
- attestral/rules/__init__.py +3 -0
- attestral/rules/core_rules.yaml +93 -0
- attestral/rules/engine.py +101 -0
- attestral-0.1.0.dist-info/METADATA +121 -0
- attestral-0.1.0.dist-info/RECORD +19 -0
- attestral-0.1.0.dist-info/WHEEL +4 -0
- attestral-0.1.0.dist-info/entry_points.txt +2 -0
- attestral-0.1.0.dist-info/licenses/LICENSE +15 -0
attestral/__init__.py
ADDED
attestral/cli.py
ADDED
|
@@ -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()
|
attestral/compile.py
ADDED
|
@@ -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)
|
attestral/drift.py
ADDED
|
@@ -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
|
attestral/evidence.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Evidence layer: tamper-evident audit chain + report export."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime as _dt
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from attestral.model import Finding, SystemModel
|
|
9
|
+
|
|
10
|
+
GENESIS = "0" * 64
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def audit_chain(findings: list[Finding]) -> list[dict]:
|
|
14
|
+
"""SHA-256 hash chain over findings: entry N commits to entry N-1.
|
|
15
|
+
|
|
16
|
+
Any modification, insertion, or deletion of a past entry changes every
|
|
17
|
+
subsequent hash - the chain head is the integrity commitment for the run.
|
|
18
|
+
"""
|
|
19
|
+
prev = GENESIS
|
|
20
|
+
chain = []
|
|
21
|
+
for f in findings:
|
|
22
|
+
payload = json.dumps(f.to_dict(), sort_keys=True)
|
|
23
|
+
digest = hashlib.sha256((prev + payload).encode()).hexdigest()
|
|
24
|
+
chain.append({"hash": digest, "prev": prev, "finding": f.to_dict()})
|
|
25
|
+
prev = digest
|
|
26
|
+
return chain
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def verify_chain(chain: list[dict]) -> bool:
|
|
30
|
+
prev = GENESIS
|
|
31
|
+
for entry in chain:
|
|
32
|
+
payload = json.dumps(entry["finding"], sort_keys=True)
|
|
33
|
+
if entry["prev"] != prev:
|
|
34
|
+
return False
|
|
35
|
+
if hashlib.sha256((prev + payload).encode()).hexdigest() != entry["hash"]:
|
|
36
|
+
return False
|
|
37
|
+
prev = entry["hash"]
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_markdown(model: SystemModel, findings: list[Finding], target: str) -> str:
|
|
42
|
+
chain = audit_chain(findings)
|
|
43
|
+
head = chain[-1]["hash"] if chain else GENESIS
|
|
44
|
+
now = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
45
|
+
counts: dict[str, int] = {}
|
|
46
|
+
for f in findings:
|
|
47
|
+
counts[f.severity.value] = counts.get(f.severity.value, 0) + 1
|
|
48
|
+
lines = [
|
|
49
|
+
"# Attestral - Security Design Review",
|
|
50
|
+
"",
|
|
51
|
+
f"- **Target:** `{target}`",
|
|
52
|
+
f"- **Generated:** {now}",
|
|
53
|
+
f"- **Components modeled:** {len(model.components)}",
|
|
54
|
+
f"- **Findings:** {len(findings)} "
|
|
55
|
+
f"({', '.join(f'{v} {k}' for k, v in counts.items()) or 'none'})",
|
|
56
|
+
f"- **Evidence chain head:** `{head}`",
|
|
57
|
+
"",
|
|
58
|
+
"## Findings",
|
|
59
|
+
"",
|
|
60
|
+
]
|
|
61
|
+
if not findings:
|
|
62
|
+
lines.append("No findings from the deterministic rule pack. ✅")
|
|
63
|
+
for i, f in enumerate(findings, 1):
|
|
64
|
+
lines += [
|
|
65
|
+
f"### {i}. [{f.severity.value.upper()}] {f.title} `{f.rule_id}`",
|
|
66
|
+
"",
|
|
67
|
+
f"- **Component:** `{f.component_id}` · **Source:** `{f.source}`",
|
|
68
|
+
f"- **Frameworks:** {', '.join(f.framework_refs) or '-'}",
|
|
69
|
+
"",
|
|
70
|
+
f.description,
|
|
71
|
+
"",
|
|
72
|
+
f"**Recommendation:** {f.recommendation}",
|
|
73
|
+
"",
|
|
74
|
+
]
|
|
75
|
+
lines += [
|
|
76
|
+
"## Evidence chain",
|
|
77
|
+
"",
|
|
78
|
+
"| # | Rule | Hash (first 16) | Prev (first 16) |",
|
|
79
|
+
"|---|------|-----------------|-----------------|",
|
|
80
|
+
]
|
|
81
|
+
for i, e in enumerate(chain, 1):
|
|
82
|
+
lines.append(
|
|
83
|
+
f"| {i} | {e['finding']['rule_id']} | `{e['hash'][:16]}` | `{e['prev'][:16]}` |"
|
|
84
|
+
)
|
|
85
|
+
lines += [
|
|
86
|
+
"",
|
|
87
|
+
"_Verify with `attestral verify report.json`. Any tampering with a past",
|
|
88
|
+
"entry invalidates every later hash and the chain head above._",
|
|
89
|
+
"",
|
|
90
|
+
]
|
|
91
|
+
return "\n".join(lines)
|
attestral/ingest/mcp.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""MCP server configuration ingestion (claude_desktop_config.json / .mcp.json style)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from attestral.model import Component, SystemModel
|
|
8
|
+
|
|
9
|
+
_SECRET_HINTS = ("KEY", "SECRET", "TOKEN", "PASSWORD", "CREDENTIAL")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def ingest_mcp(path: str | Path, model: SystemModel) -> SystemModel:
|
|
13
|
+
p = Path(path)
|
|
14
|
+
files = [p] if p.is_file() else sorted(
|
|
15
|
+
list(p.rglob("*.mcp.json")) + list(p.rglob("mcp*.json")) + list(p.rglob("claude_desktop_config.json"))
|
|
16
|
+
)
|
|
17
|
+
for f in files:
|
|
18
|
+
try:
|
|
19
|
+
data = json.loads(f.read_text(errors="ignore"))
|
|
20
|
+
except json.JSONDecodeError:
|
|
21
|
+
continue
|
|
22
|
+
servers = data.get("mcpServers") or data.get("servers") or {}
|
|
23
|
+
for name, cfg in servers.items():
|
|
24
|
+
attrs: dict = {}
|
|
25
|
+
if isinstance(cfg, dict):
|
|
26
|
+
attrs["command"] = cfg.get("command", "")
|
|
27
|
+
attrs["args"] = cfg.get("args", [])
|
|
28
|
+
attrs["url"] = cfg.get("url", "")
|
|
29
|
+
env = cfg.get("env", {}) or {}
|
|
30
|
+
attrs["env_keys"] = list(env.keys())
|
|
31
|
+
attrs["_env_has_secrets"] = any(
|
|
32
|
+
any(h in k.upper() for h in _SECRET_HINTS) for k in env
|
|
33
|
+
)
|
|
34
|
+
model.add(
|
|
35
|
+
Component(
|
|
36
|
+
id=f"mcp_server.{name}",
|
|
37
|
+
type="mcp_server",
|
|
38
|
+
name=name,
|
|
39
|
+
source=str(f),
|
|
40
|
+
attributes=attrs,
|
|
41
|
+
trust_boundary="agent_runtime",
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
return model
|
attestral/ingest/scan.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Directory scanner: routes files to the right ingesters and returns one model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from attestral.ingest.mcp import ingest_mcp
|
|
7
|
+
from attestral.ingest.terraform import ingest_terraform
|
|
8
|
+
from attestral.model import SystemModel, TrustBoundary
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_model(path: str | Path) -> SystemModel:
|
|
12
|
+
model = SystemModel(
|
|
13
|
+
boundaries=[
|
|
14
|
+
TrustBoundary("cloud", "Cloud infrastructure"),
|
|
15
|
+
TrustBoundary("agent_runtime", "Agent / MCP runtime"),
|
|
16
|
+
]
|
|
17
|
+
)
|
|
18
|
+
ingest_terraform(path, model)
|
|
19
|
+
ingest_mcp(path, model)
|
|
20
|
+
return model
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Lightweight Terraform (HCL) ingestion.
|
|
2
|
+
|
|
3
|
+
Deliberately dependency-free: a pragmatic block/attribute scanner, not a full
|
|
4
|
+
HCL parser. Good enough to build a design-level model; swap in python-hcl2
|
|
5
|
+
later for full fidelity.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from attestral.model import Component, SystemModel
|
|
13
|
+
|
|
14
|
+
_RESOURCE_RE = re.compile(r'resource\s+"([\w-]+)"\s+"([\w-]+)"\s*\{', re.MULTILINE)
|
|
15
|
+
_ATTR_RE = re.compile(r'^\s*([\w]+)\s*=\s*(.+?)\s*$', re.MULTILINE)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _block_body(text: str, start: int) -> str:
|
|
19
|
+
"""Return the text of the brace-balanced block starting at `start` ('{')."""
|
|
20
|
+
depth, i = 0, start
|
|
21
|
+
while i < len(text):
|
|
22
|
+
if text[i] == "{":
|
|
23
|
+
depth += 1
|
|
24
|
+
elif text[i] == "}":
|
|
25
|
+
depth -= 1
|
|
26
|
+
if depth == 0:
|
|
27
|
+
return text[start + 1 : i]
|
|
28
|
+
i += 1
|
|
29
|
+
return text[start + 1 :]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _clean(value: str):
|
|
33
|
+
v = value.strip().rstrip(",")
|
|
34
|
+
if v.startswith('"') and v.endswith('"'):
|
|
35
|
+
return v[1:-1].replace('\\"', '"')
|
|
36
|
+
if v in ("true", "false"):
|
|
37
|
+
return v == "true"
|
|
38
|
+
return v
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ingest_terraform(path: str | Path, model: SystemModel) -> SystemModel:
|
|
42
|
+
p = Path(path)
|
|
43
|
+
files = [p] if p.is_file() else sorted(p.rglob("*.tf"))
|
|
44
|
+
for f in files:
|
|
45
|
+
if _ingest_with_hcl2(f, model):
|
|
46
|
+
continue
|
|
47
|
+
_ingest_with_scanner(f, model)
|
|
48
|
+
return model
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --- full parser (optional extra: pip install "attestral[terraform]") -------
|
|
52
|
+
|
|
53
|
+
def _unq(v):
|
|
54
|
+
"""Normalize python-hcl2 output: strip HCL string quoting, recurse containers."""
|
|
55
|
+
if isinstance(v, str):
|
|
56
|
+
if v.startswith('"') and v.endswith('"'):
|
|
57
|
+
v = v[1:-1]
|
|
58
|
+
return v.replace('\\"', '"')
|
|
59
|
+
if isinstance(v, list):
|
|
60
|
+
return [_unq(x) for x in v]
|
|
61
|
+
if isinstance(v, dict):
|
|
62
|
+
return {_unq(k): _unq(x) for k, x in v.items() if k != "__is_block__"}
|
|
63
|
+
return v
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _flatten(attrs: dict, out: dict, cidrs: list) -> None:
|
|
67
|
+
for k, v in attrs.items():
|
|
68
|
+
if isinstance(v, dict):
|
|
69
|
+
_flatten(v, out, cidrs)
|
|
70
|
+
elif isinstance(v, list) and v and isinstance(v[0], dict):
|
|
71
|
+
for block in v:
|
|
72
|
+
_flatten(block, out, cidrs)
|
|
73
|
+
else:
|
|
74
|
+
out[k] = v
|
|
75
|
+
if k == "cidr_blocks" and isinstance(v, list):
|
|
76
|
+
cidrs.extend(str(x) for x in v)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ingest_with_hcl2(f: Path, model: SystemModel) -> bool:
|
|
80
|
+
try:
|
|
81
|
+
import hcl2
|
|
82
|
+
except ImportError:
|
|
83
|
+
return False
|
|
84
|
+
try:
|
|
85
|
+
with f.open() as fh:
|
|
86
|
+
data = hcl2.load(fh)
|
|
87
|
+
except Exception:
|
|
88
|
+
return False # malformed file: let the lenient scanner have a try
|
|
89
|
+
for block in _unq(data).get("resource", []):
|
|
90
|
+
for rtype, instances in block.items():
|
|
91
|
+
for rname, raw in instances.items():
|
|
92
|
+
attrs: dict = {}
|
|
93
|
+
cidrs: list[str] = []
|
|
94
|
+
_flatten(raw if isinstance(raw, dict) else {}, attrs, cidrs)
|
|
95
|
+
if cidrs:
|
|
96
|
+
attrs["_cidr_blocks"] = cidrs
|
|
97
|
+
model.add(
|
|
98
|
+
Component(
|
|
99
|
+
id=f"{rtype}.{rname}",
|
|
100
|
+
type=rtype,
|
|
101
|
+
name=rname,
|
|
102
|
+
source=str(f),
|
|
103
|
+
attributes=attrs,
|
|
104
|
+
trust_boundary="cloud",
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# --- dependency-free fallback scanner ---------------------------------------
|
|
111
|
+
|
|
112
|
+
def _ingest_with_scanner(f: Path, model: SystemModel) -> None:
|
|
113
|
+
text = f.read_text(errors="ignore")
|
|
114
|
+
for m in _RESOURCE_RE.finditer(text):
|
|
115
|
+
rtype, rname = m.group(1), m.group(2)
|
|
116
|
+
body = _block_body(text, text.index("{", m.end() - 1))
|
|
117
|
+
attrs = {k: _clean(v) for k, v in _ATTR_RE.findall(body)}
|
|
118
|
+
cidrs = re.findall(r'cidr_blocks\s*=\s*\[([^\]]*)\]', body)
|
|
119
|
+
if cidrs:
|
|
120
|
+
attrs["_cidr_blocks"] = re.findall(r'"([^"]+)"', ",".join(cidrs))
|
|
121
|
+
model.add(
|
|
122
|
+
Component(
|
|
123
|
+
id=f"{rtype}.{rname}",
|
|
124
|
+
type=rtype,
|
|
125
|
+
name=rname,
|
|
126
|
+
source=str(f),
|
|
127
|
+
attributes=attrs,
|
|
128
|
+
trust_boundary="cloud",
|
|
129
|
+
)
|
|
130
|
+
)
|
attestral/llm.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Optional LLM threat elicitation layer.
|
|
2
|
+
|
|
3
|
+
Runs only when --llm is passed and ANTHROPIC_API_KEY is set. Findings from
|
|
4
|
+
this layer are tagged origin="llm" and never mixed silently with the
|
|
5
|
+
deterministic layer - regulated buyers need to know which is which.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from attestral.model import Finding, Severity, SystemModel
|
|
13
|
+
|
|
14
|
+
_SYSTEM = (
|
|
15
|
+
"You are a principal security engineer performing a design review. "
|
|
16
|
+
"Given a JSON system model (components, edges, trust boundaries), identify "
|
|
17
|
+
"design-level threats the deterministic rules would miss: authn/authz gaps, "
|
|
18
|
+
"trust-boundary violations, agentic risks (prompt-injection paths, tool "
|
|
19
|
+
"permission escalation, handoff confusion). Respond ONLY with a JSON array; "
|
|
20
|
+
"each item: {\"title\", \"severity\" (critical|high|medium|low|info), "
|
|
21
|
+
"\"component_id\", \"description\", \"recommendation\"}. No markdown, no preamble."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def elicit(model: SystemModel, max_findings: int = 10) -> list[Finding]:
|
|
26
|
+
if not os.environ.get("ANTHROPIC_API_KEY"):
|
|
27
|
+
return []
|
|
28
|
+
try:
|
|
29
|
+
import anthropic
|
|
30
|
+
except ImportError:
|
|
31
|
+
return []
|
|
32
|
+
client = anthropic.Anthropic()
|
|
33
|
+
msg = client.messages.create(
|
|
34
|
+
model="claude-sonnet-4-6",
|
|
35
|
+
max_tokens=2000,
|
|
36
|
+
system=_SYSTEM,
|
|
37
|
+
messages=[{"role": "user", "content": model.to_json()[:100_000]}],
|
|
38
|
+
)
|
|
39
|
+
text = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
|
|
40
|
+
text = text.replace("```json", "").replace("```", "").strip()
|
|
41
|
+
try:
|
|
42
|
+
items = json.loads(text)
|
|
43
|
+
except json.JSONDecodeError:
|
|
44
|
+
return []
|
|
45
|
+
findings = []
|
|
46
|
+
for i, item in enumerate(items[:max_findings]):
|
|
47
|
+
try:
|
|
48
|
+
findings.append(
|
|
49
|
+
Finding(
|
|
50
|
+
rule_id=f"ATL-LLM-{i+1:03d}",
|
|
51
|
+
title=str(item.get("title", "LLM finding")),
|
|
52
|
+
severity=Severity(str(item.get("severity", "info")).lower()),
|
|
53
|
+
component_id=str(item.get("component_id", "model")),
|
|
54
|
+
description=str(item.get("description", "")),
|
|
55
|
+
recommendation=str(item.get("recommendation", "")),
|
|
56
|
+
source="llm-elicitation",
|
|
57
|
+
origin="llm",
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
except (ValueError, TypeError):
|
|
61
|
+
continue
|
|
62
|
+
return findings
|
attestral/model.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Unified system model: components, edges, trust boundaries, findings."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass, field, asdict
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Severity(str, Enum):
|
|
11
|
+
CRITICAL = "critical"
|
|
12
|
+
HIGH = "high"
|
|
13
|
+
MEDIUM = "medium"
|
|
14
|
+
LOW = "low"
|
|
15
|
+
INFO = "info"
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def rank(self) -> int:
|
|
19
|
+
return {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0}[self.value]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Component:
|
|
24
|
+
"""A node in the system model: cloud resource, service, MCP server, agent, datastore."""
|
|
25
|
+
id: str
|
|
26
|
+
type: str # e.g. aws_s3_bucket, mcp_server, agent, service
|
|
27
|
+
name: str
|
|
28
|
+
source: str # file path the component was ingested from
|
|
29
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
trust_boundary: str | None = None
|
|
31
|
+
|
|
32
|
+
def attr(self, key: str, default: Any = None) -> Any:
|
|
33
|
+
return self.attributes.get(key, default)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Edge:
|
|
38
|
+
"""A directed relationship: data flow, invocation, tool access, network path."""
|
|
39
|
+
source_id: str
|
|
40
|
+
target_id: str
|
|
41
|
+
kind: str = "dataflow" # dataflow | invokes | tool_access | network
|
|
42
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class TrustBoundary:
|
|
47
|
+
id: str
|
|
48
|
+
name: str
|
|
49
|
+
description: str = ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Finding:
|
|
54
|
+
rule_id: str
|
|
55
|
+
title: str
|
|
56
|
+
severity: Severity
|
|
57
|
+
component_id: str
|
|
58
|
+
description: str
|
|
59
|
+
recommendation: str
|
|
60
|
+
source: str = ""
|
|
61
|
+
framework_refs: list[str] = field(default_factory=list) # e.g. ["ASVS V1.2", "NIST AC-3"]
|
|
62
|
+
origin: str = "deterministic" # deterministic | llm
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, Any]:
|
|
65
|
+
d = asdict(self)
|
|
66
|
+
d["severity"] = self.severity.value
|
|
67
|
+
return d
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class SystemModel:
|
|
72
|
+
components: list[Component] = field(default_factory=list)
|
|
73
|
+
edges: list[Edge] = field(default_factory=list)
|
|
74
|
+
boundaries: list[TrustBoundary] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
def add(self, component: Component) -> None:
|
|
77
|
+
self.components.append(component)
|
|
78
|
+
|
|
79
|
+
def by_type(self, prefix: str) -> list[Component]:
|
|
80
|
+
return [c for c in self.components if c.type.startswith(prefix)]
|
|
81
|
+
|
|
82
|
+
def get(self, component_id: str) -> Component | None:
|
|
83
|
+
return next((c for c in self.components if c.id == component_id), None)
|
|
84
|
+
|
|
85
|
+
def crossing_edges(self) -> list[Edge]:
|
|
86
|
+
"""Edges whose endpoints sit in different trust boundaries."""
|
|
87
|
+
out = []
|
|
88
|
+
for e in self.edges:
|
|
89
|
+
s, t = self.get(e.source_id), self.get(e.target_id)
|
|
90
|
+
if s and t and s.trust_boundary != t.trust_boundary:
|
|
91
|
+
out.append(e)
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
def to_json(self) -> str:
|
|
95
|
+
return json.dumps(
|
|
96
|
+
{
|
|
97
|
+
"components": [asdict(c) for c in self.components],
|
|
98
|
+
"edges": [asdict(e) for e in self.edges],
|
|
99
|
+
"boundaries": [asdict(b) for b in self.boundaries],
|
|
100
|
+
},
|
|
101
|
+
indent=2,
|
|
102
|
+
default=str,
|
|
103
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Attestral core rule pack (v0)
|
|
2
|
+
# Deterministic, design-level checks. Each rule targets a component type
|
|
3
|
+
# and applies structured matchers against ingested attributes.
|
|
4
|
+
rules:
|
|
5
|
+
- id: ATL-001
|
|
6
|
+
title: S3 bucket allows public access
|
|
7
|
+
severity: critical
|
|
8
|
+
target: aws_s3_bucket
|
|
9
|
+
match: { attr_in: { acl: ["public-read", "public-read-write"] } }
|
|
10
|
+
description: Bucket ACL grants access to all users, exposing stored data to the internet.
|
|
11
|
+
recommendation: Remove the public ACL and enforce account-level S3 Block Public Access.
|
|
12
|
+
frameworks: ["NIST AC-3", "ASVS V4.1", "SOC2 CC6.1"]
|
|
13
|
+
|
|
14
|
+
- id: ATL-002
|
|
15
|
+
title: Security group open to the world
|
|
16
|
+
severity: high
|
|
17
|
+
target: aws_security_group
|
|
18
|
+
match: { attr_list_contains: { _cidr_blocks: "0.0.0.0/0" } }
|
|
19
|
+
description: Ingress permits traffic from any IPv4 address, expanding the attack surface to the entire internet.
|
|
20
|
+
recommendation: Restrict ingress CIDRs to known ranges or place the service behind a load balancer/VPN.
|
|
21
|
+
frameworks: ["NIST SC-7", "SOC2 CC6.6"]
|
|
22
|
+
|
|
23
|
+
- id: ATL-003
|
|
24
|
+
title: IAM policy grants wildcard actions
|
|
25
|
+
severity: critical
|
|
26
|
+
target: aws_iam_policy
|
|
27
|
+
match: { attr_contains: { policy: '"Action": "*"' } }
|
|
28
|
+
description: Wildcard IAM actions violate least privilege; a compromised principal gains full account control.
|
|
29
|
+
recommendation: Enumerate required actions explicitly; use IAM Access Analyzer to right-size.
|
|
30
|
+
frameworks: ["NIST AC-6", "ASVS V4.1", "SOC2 CC6.3"]
|
|
31
|
+
|
|
32
|
+
- id: ATL-004
|
|
33
|
+
title: Database publicly accessible
|
|
34
|
+
severity: critical
|
|
35
|
+
target: aws_db_instance
|
|
36
|
+
match: { attr_equals: { publicly_accessible: true } }
|
|
37
|
+
description: The database endpoint is reachable from the public internet.
|
|
38
|
+
recommendation: Set publicly_accessible = false and access the DB through private subnets or a bastion.
|
|
39
|
+
frameworks: ["NIST SC-7", "SOC2 CC6.1"]
|
|
40
|
+
|
|
41
|
+
- id: ATL-005
|
|
42
|
+
title: Storage encryption not declared
|
|
43
|
+
severity: medium
|
|
44
|
+
target: aws_db_instance
|
|
45
|
+
match: { attr_missing: storage_encrypted }
|
|
46
|
+
description: Encryption at rest is not declared in the design; data may be stored in plaintext.
|
|
47
|
+
recommendation: Set storage_encrypted = true (and kms_key_id for CMK control).
|
|
48
|
+
frameworks: ["NIST SC-28", "ASVS V6.1", "SOC2 CC6.7"]
|
|
49
|
+
|
|
50
|
+
- id: ATL-101
|
|
51
|
+
title: MCP server uses non-TLS transport
|
|
52
|
+
severity: high
|
|
53
|
+
target: mcp_server
|
|
54
|
+
match: { attr_starts_with: { url: "http://" } }
|
|
55
|
+
description: Tool traffic (including prompts and tool results) crosses the network unencrypted.
|
|
56
|
+
recommendation: Serve the MCP endpoint over HTTPS/WSS only.
|
|
57
|
+
frameworks: ["NIST SC-8", "OWASP-AgSec TLS-1"]
|
|
58
|
+
|
|
59
|
+
- id: ATL-102
|
|
60
|
+
title: Filesystem MCP server rooted at a broad path
|
|
61
|
+
severity: high
|
|
62
|
+
target: mcp_server
|
|
63
|
+
match: { attr_list_any_of: { args: ["/", "~", "/home", "/Users"] } }
|
|
64
|
+
description: The agent gains read/write reach over a broad filesystem root, far beyond task scope.
|
|
65
|
+
recommendation: Scope the server to the narrowest project directory that supports the workflow.
|
|
66
|
+
frameworks: ["NIST AC-6", "OWASP-AgSec TOOL-2"]
|
|
67
|
+
|
|
68
|
+
- id: ATL-103
|
|
69
|
+
title: Shell-capable MCP server configured
|
|
70
|
+
severity: critical
|
|
71
|
+
target: mcp_server
|
|
72
|
+
match: { attr_any_contains: { command: ["bash", "sh", "cmd", "powershell"], args: ["bash", "sh -c", "shell"] } }
|
|
73
|
+
description: The agent can execute arbitrary shell commands; a single prompt injection becomes code execution.
|
|
74
|
+
recommendation: Replace generic shell access with narrowly scoped, allowlisted tools; gate with human approval.
|
|
75
|
+
frameworks: ["OWASP-AgSec TOOL-1", "NIST AC-6"]
|
|
76
|
+
|
|
77
|
+
- id: ATL-104
|
|
78
|
+
title: Secrets passed to MCP server via environment
|
|
79
|
+
severity: medium
|
|
80
|
+
target: mcp_server
|
|
81
|
+
match: { attr_equals: { _env_has_secrets: true } }
|
|
82
|
+
description: Credentials in tool-server env vars are exposed to the tool process and often to model context.
|
|
83
|
+
recommendation: Use a secret manager or OS keychain; never place raw credentials where tool output can echo them.
|
|
84
|
+
frameworks: ["NIST IA-5", "ASVS V2.10", "OWASP-AgSec CRED-1"]
|
|
85
|
+
|
|
86
|
+
- id: ATL-201
|
|
87
|
+
title: Agent runtime and cloud share no declared boundary controls
|
|
88
|
+
severity: info
|
|
89
|
+
target: model
|
|
90
|
+
match: { model_has_both: ["mcp_server", "aws_"] }
|
|
91
|
+
description: The design contains both agent tooling and cloud resources but no declared control between the two trust zones.
|
|
92
|
+
recommendation: Document the authn/authz path from agent runtime to cloud APIs; deny by default.
|
|
93
|
+
frameworks: ["NIST AC-4", "OWASP-AgSec ARCH-1"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Deterministic rule engine: structured matchers over the system model.
|
|
2
|
+
|
|
3
|
+
No eval(), no string execution - every matcher is a named, typed check.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from attestral.model import Component, Finding, Severity, SystemModel
|
|
13
|
+
|
|
14
|
+
_CORE = Path(__file__).parent / "core_rules.yaml"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _matches(component: Component, match: dict[str, Any]) -> bool:
|
|
18
|
+
for kind, spec in match.items():
|
|
19
|
+
if kind == "attr_equals":
|
|
20
|
+
if not all(component.attr(k) == v for k, v in spec.items()):
|
|
21
|
+
return False
|
|
22
|
+
elif kind == "attr_in":
|
|
23
|
+
if not all(component.attr(k) in v for k, v in spec.items()):
|
|
24
|
+
return False
|
|
25
|
+
elif kind == "attr_missing":
|
|
26
|
+
keys = spec if isinstance(spec, list) else [spec]
|
|
27
|
+
if not all(component.attr(k) is None for k in keys):
|
|
28
|
+
return False
|
|
29
|
+
elif kind == "attr_starts_with":
|
|
30
|
+
if not all(str(component.attr(k, "")).startswith(v) for k, v in spec.items()):
|
|
31
|
+
return False
|
|
32
|
+
elif kind == "attr_contains":
|
|
33
|
+
if not all(v in str(component.attr(k, "")) for k, v in spec.items()):
|
|
34
|
+
return False
|
|
35
|
+
elif kind == "attr_list_contains":
|
|
36
|
+
if not all(v in (component.attr(k) or []) for k, v in spec.items()):
|
|
37
|
+
return False
|
|
38
|
+
elif kind == "attr_list_any_of":
|
|
39
|
+
ok = False
|
|
40
|
+
for k, values in spec.items():
|
|
41
|
+
items = [str(x) for x in (component.attr(k) or [])]
|
|
42
|
+
if any(any(v == i or i.startswith(v + "/") or v in i for i in items) for v in values):
|
|
43
|
+
ok = True
|
|
44
|
+
if not ok:
|
|
45
|
+
return False
|
|
46
|
+
elif kind == "attr_any_contains":
|
|
47
|
+
ok = False
|
|
48
|
+
for k, values in spec.items():
|
|
49
|
+
hay = component.attr(k)
|
|
50
|
+
hay = " ".join(str(x) for x in hay) if isinstance(hay, list) else str(hay or "")
|
|
51
|
+
if any(v in hay for v in values):
|
|
52
|
+
ok = True
|
|
53
|
+
if not ok:
|
|
54
|
+
return False
|
|
55
|
+
else:
|
|
56
|
+
return False # unknown matcher: fail closed
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RuleEngine:
|
|
61
|
+
def __init__(self, rule_paths: list[str | Path] | None = None):
|
|
62
|
+
paths = [_CORE] + [Path(p) for p in (rule_paths or [])]
|
|
63
|
+
self.rules: list[dict] = []
|
|
64
|
+
for p in paths:
|
|
65
|
+
data = yaml.safe_load(Path(p).read_text()) or {}
|
|
66
|
+
self.rules.extend(data.get("rules", []))
|
|
67
|
+
|
|
68
|
+
def evaluate(self, model: SystemModel) -> list[Finding]:
|
|
69
|
+
findings: list[Finding] = []
|
|
70
|
+
for rule in self.rules:
|
|
71
|
+
target = rule.get("target", "")
|
|
72
|
+
match = rule.get("match", {})
|
|
73
|
+
if target == "model":
|
|
74
|
+
findings.extend(self._evaluate_model_rule(rule, match, model))
|
|
75
|
+
continue
|
|
76
|
+
for c in model.by_type(target):
|
|
77
|
+
if _matches(c, match):
|
|
78
|
+
findings.append(self._finding(rule, c.id, c.source))
|
|
79
|
+
findings.sort(key=lambda f: f.severity.rank, reverse=True)
|
|
80
|
+
return findings
|
|
81
|
+
|
|
82
|
+
def _evaluate_model_rule(self, rule: dict, match: dict, model: SystemModel) -> list[Finding]:
|
|
83
|
+
if "model_has_both" in match:
|
|
84
|
+
a, b = match["model_has_both"]
|
|
85
|
+
if model.by_type(a) and model.by_type(b):
|
|
86
|
+
return [self._finding(rule, "model", "system model")]
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _finding(rule: dict, component_id: str, source: str) -> Finding:
|
|
91
|
+
return Finding(
|
|
92
|
+
rule_id=rule["id"],
|
|
93
|
+
title=rule["title"],
|
|
94
|
+
severity=Severity(rule["severity"]),
|
|
95
|
+
component_id=component_id,
|
|
96
|
+
description=rule.get("description", ""),
|
|
97
|
+
recommendation=rule.get("recommendation", ""),
|
|
98
|
+
source=source,
|
|
99
|
+
framework_refs=rule.get("frameworks", []),
|
|
100
|
+
origin="deterministic",
|
|
101
|
+
)
|
|
@@ -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,19 @@
|
|
|
1
|
+
attestral/__init__.py,sha256=MBZxfVWITQ1dA3-FQ7suNAkmdqkKQo8oH-PGchO5rnM,245
|
|
2
|
+
attestral/cli.py,sha256=cNQS5TIHxTldqNlCZkyvm6VVZ7c9EKv4DaLToiVUzZw,4621
|
|
3
|
+
attestral/compile.py,sha256=6eRijSke89rrP838gwPeHY--f86FYatj0s0APEi7IjA,3622
|
|
4
|
+
attestral/drift.py,sha256=h4_0aq2E1IVP0IOj1pnoM4F0dkgAYXLCDDLNbtRG0D4,3171
|
|
5
|
+
attestral/evidence.py,sha256=d4v6oClMyzdQGAtbYt70aX4lnxv5exwLbpbfFDO40Pg,3097
|
|
6
|
+
attestral/llm.py,sha256=M0h9w4ZK-L3B7_YwGnPVEC0GfUVoAli_kx2UTHAid9Q,2384
|
|
7
|
+
attestral/model.py,sha256=SygO3E2HXvU_FpIxEHCZlwyuwi4zLADn_f3_MdznMVo,3076
|
|
8
|
+
attestral/ingest/__init__.py,sha256=2TCIdjq7q2o8xj6tn0LDmOegk3T6IS19tgp66tlMFjs,207
|
|
9
|
+
attestral/ingest/mcp.py,sha256=b8nH9sCPAN8tuf1uepwr3iWsb3RjAgUMhenJpD7oA20,1607
|
|
10
|
+
attestral/ingest/scan.py,sha256=X-YewzzKEYvbzVK61Q00oYfQMufgMB3YFUskPgWe4Pk,621
|
|
11
|
+
attestral/ingest/terraform.py,sha256=Pqt4bW-Ch-tJCfUdaUiUVor9MAtpBLOn9hRyTu1Vfhk,4222
|
|
12
|
+
attestral/rules/__init__.py,sha256=J14ai9CbgcNegaQ0xNfAZPeM76wiAXDVXsIyXSf2uns,72
|
|
13
|
+
attestral/rules/core_rules.yaml,sha256=qY2aXZebj_r4WhKEStKGkP7gLdqruKGd3UGU2kA8SVg,4528
|
|
14
|
+
attestral/rules/engine.py,sha256=iMjk7M-DNGnhQC6hLZOX4_a_lvHZ66RBzg-8LIX7Bko,3984
|
|
15
|
+
attestral-0.1.0.dist-info/METADATA,sha256=HQBdTJx8Kph0Xpk2LOhyISvkvIiuVa4efKDqQ7COH0o,4299
|
|
16
|
+
attestral-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
17
|
+
attestral-0.1.0.dist-info/entry_points.txt,sha256=wICz2OqyydNSxttRcr0KuZ9f-ju02TBL5BwlT_kUgqg,49
|
|
18
|
+
attestral-0.1.0.dist-info/licenses/LICENSE,sha256=fJyJtKmZmQILfa_KJ-mJd_35kX8Cy7fLtP7EHVw8zDg,570
|
|
19
|
+
attestral-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|