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 ADDED
@@ -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"]
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)
@@ -0,0 +1,5 @@
1
+ from attestral.ingest.terraform import ingest_terraform
2
+ from attestral.ingest.mcp import ingest_mcp
3
+ from attestral.ingest.scan import build_model
4
+
5
+ __all__ = ["ingest_terraform", "ingest_mcp", "build_model"]
@@ -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
@@ -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,3 @@
1
+ from attestral.rules.engine import RuleEngine
2
+
3
+ __all__ = ["RuleEngine"]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ attestral = attestral.cli:main
@@ -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.