agentveil-posture 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.
- agentveil_posture/__init__.py +6 -0
- agentveil_posture/cli.py +98 -0
- agentveil_posture/report.py +210 -0
- agentveil_posture/rules/__init__.py +21 -0
- agentveil_posture/rules/identity.py +51 -0
- agentveil_posture/rules/manifest.py +166 -0
- agentveil_posture/rules/parsing.py +72 -0
- agentveil_posture/rules/workflow.py +182 -0
- agentveil_posture/scanner.py +61 -0
- agentveil_posture-0.1.0.dist-info/METADATA +293 -0
- agentveil_posture-0.1.0.dist-info/RECORD +15 -0
- agentveil_posture-0.1.0.dist-info/WHEEL +5 -0
- agentveil_posture-0.1.0.dist-info/entry_points.txt +2 -0
- agentveil_posture-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentveil_posture-0.1.0.dist-info/top_level.txt +1 -0
agentveil_posture/cli.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Command-line interface for AgentVeil Posture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
from agentveil_posture.report import SEVERITIES, Finding, PostureReport
|
|
12
|
+
from agentveil_posture.scanner import ScanError, scan_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="agentveil",
|
|
18
|
+
description="AgentVeil developer tools.",
|
|
19
|
+
)
|
|
20
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
21
|
+
|
|
22
|
+
posture_parser = subparsers.add_parser(
|
|
23
|
+
"posture",
|
|
24
|
+
help="Run static posture checks.",
|
|
25
|
+
)
|
|
26
|
+
posture_subparsers = posture_parser.add_subparsers(
|
|
27
|
+
dest="posture_command",
|
|
28
|
+
required=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
scan_parser = posture_subparsers.add_parser(
|
|
32
|
+
"scan",
|
|
33
|
+
help="Scan a project and write a posture report.",
|
|
34
|
+
)
|
|
35
|
+
scan_parser.add_argument(
|
|
36
|
+
"--path",
|
|
37
|
+
default=".",
|
|
38
|
+
help="Project path to scan. Defaults to the current directory.",
|
|
39
|
+
)
|
|
40
|
+
scan_parser.add_argument(
|
|
41
|
+
"--output",
|
|
42
|
+
required=True,
|
|
43
|
+
help="Path where the report will be written.",
|
|
44
|
+
)
|
|
45
|
+
scan_parser.add_argument(
|
|
46
|
+
"--format",
|
|
47
|
+
choices=("json", "sarif"),
|
|
48
|
+
default="json",
|
|
49
|
+
help="Report format to write. Defaults to json.",
|
|
50
|
+
)
|
|
51
|
+
scan_parser.add_argument(
|
|
52
|
+
"--fail-on",
|
|
53
|
+
choices=SEVERITIES,
|
|
54
|
+
default=None,
|
|
55
|
+
help="Exit 1 when findings at or above this severity are present.",
|
|
56
|
+
)
|
|
57
|
+
scan_parser.set_defaults(handler=_handle_posture_scan)
|
|
58
|
+
|
|
59
|
+
return parser
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _finding_meets_threshold(finding: Finding, threshold: str) -> bool:
|
|
63
|
+
return SEVERITIES.index(finding.severity) <= SEVERITIES.index(threshold)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _report_meets_threshold(report: PostureReport, threshold: str | None) -> bool:
|
|
67
|
+
if threshold is None:
|
|
68
|
+
return False
|
|
69
|
+
return any(_finding_meets_threshold(finding, threshold) for finding in report.findings)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _handle_posture_scan(args: argparse.Namespace) -> int:
|
|
73
|
+
try:
|
|
74
|
+
report = scan_path(Path(args.path))
|
|
75
|
+
if args.format == "json":
|
|
76
|
+
output = report.to_json()
|
|
77
|
+
else:
|
|
78
|
+
output = json.dumps(report.to_sarif(), indent=2, sort_keys=True) + "\n"
|
|
79
|
+
Path(args.output).write_text(output, encoding="utf-8")
|
|
80
|
+
if _report_meets_threshold(report, args.fail_on):
|
|
81
|
+
return 1
|
|
82
|
+
return 0
|
|
83
|
+
except (OSError, ScanError) as exc:
|
|
84
|
+
print(f"agentveil posture scan: {exc}", file=sys.stderr)
|
|
85
|
+
return 1
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
89
|
+
parser = build_parser()
|
|
90
|
+
args = parser.parse_args(argv)
|
|
91
|
+
handler = getattr(args, "handler", None)
|
|
92
|
+
if handler is None:
|
|
93
|
+
parser.error("missing command")
|
|
94
|
+
return int(handler(args))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Versioned JSON report model for AgentVeil Posture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
REPORT_VERSION = "0.1"
|
|
12
|
+
SCANNER_VERSION = "agentveil-posture/0.1.0"
|
|
13
|
+
SEVERITIES = ("critical", "high", "medium", "low", "info")
|
|
14
|
+
SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
|
|
15
|
+
SARIF_VERSION = "2.1.0"
|
|
16
|
+
SEVERITY_TO_SARIF_LEVEL = {
|
|
17
|
+
"critical": "error",
|
|
18
|
+
"high": "error",
|
|
19
|
+
"medium": "warning",
|
|
20
|
+
"low": "note",
|
|
21
|
+
"info": "note",
|
|
22
|
+
}
|
|
23
|
+
SEVERITY_TO_SECURITY_SEVERITY = {
|
|
24
|
+
"critical": "9.5",
|
|
25
|
+
"high": "8.0",
|
|
26
|
+
"medium": "5.0",
|
|
27
|
+
"low": "3.0",
|
|
28
|
+
"info": "0.0",
|
|
29
|
+
}
|
|
30
|
+
RULE_DESCRIPTORS = {
|
|
31
|
+
"bypass.direct_github_token": {
|
|
32
|
+
"short": "Direct GitHub token capability",
|
|
33
|
+
"full": "Flags direct GitHub token references in workflows or agent manifests.",
|
|
34
|
+
"help": "https://github.com/agentveil-protocol/agentveil-posture#triaging-findings",
|
|
35
|
+
},
|
|
36
|
+
"workflow.deploy_without_approval": {
|
|
37
|
+
"short": "Deployment without approval gate",
|
|
38
|
+
"full": "Flags deploy, release, or publish workflow steps without an approval signal.",
|
|
39
|
+
"help": "https://github.com/agentveil-protocol/agentveil-posture#triaging-findings",
|
|
40
|
+
},
|
|
41
|
+
"workflow.pull_request_target_secrets_risk": {
|
|
42
|
+
"short": "Privileged pull_request_target risk",
|
|
43
|
+
"full": "Flags pull_request_target workflows that combine privileged context with checkout, run, or secrets.",
|
|
44
|
+
"help": "https://github.com/agentveil-protocol/agentveil-posture#triaging-findings",
|
|
45
|
+
},
|
|
46
|
+
"tool.shell_without_approval": {
|
|
47
|
+
"short": "Shell-capable tool without approval",
|
|
48
|
+
"full": "Flags agent tool manifests that expose shell execution without an approval flag.",
|
|
49
|
+
"help": "https://github.com/agentveil-protocol/agentveil-posture#triaging-findings",
|
|
50
|
+
},
|
|
51
|
+
"identity.private_key_unencrypted": {
|
|
52
|
+
"short": "Unencrypted private key file",
|
|
53
|
+
"full": "Flags committed PEM private key files that appear to be unencrypted.",
|
|
54
|
+
"help": "https://github.com/agentveil-protocol/agentveil-posture#triaging-findings",
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def utc_now_iso() -> str:
|
|
60
|
+
return (
|
|
61
|
+
datetime.now(timezone.utc)
|
|
62
|
+
.replace(microsecond=0)
|
|
63
|
+
.isoformat()
|
|
64
|
+
.replace("+00:00", "Z")
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Finding:
|
|
70
|
+
rule_id: str
|
|
71
|
+
severity: str
|
|
72
|
+
file: str
|
|
73
|
+
line: int | None
|
|
74
|
+
message: str
|
|
75
|
+
remediation: str
|
|
76
|
+
|
|
77
|
+
def __post_init__(self) -> None:
|
|
78
|
+
if self.severity not in SEVERITIES:
|
|
79
|
+
raise ValueError(f"unknown severity: {self.severity}")
|
|
80
|
+
category, separator, name = self.rule_id.partition(".")
|
|
81
|
+
if not category or separator != "." or not name:
|
|
82
|
+
raise ValueError(f"invalid rule_id: {self.rule_id}")
|
|
83
|
+
allowed = set("abcdefghijklmnopqrstuvwxyz0123456789_")
|
|
84
|
+
if not set(category) <= allowed or not set(name) <= allowed:
|
|
85
|
+
raise ValueError(f"invalid rule_id: {self.rule_id}")
|
|
86
|
+
if (
|
|
87
|
+
self.file.startswith("/")
|
|
88
|
+
or self.file.startswith("\\\\")
|
|
89
|
+
or (len(self.file) >= 2 and self.file[1] == ":")
|
|
90
|
+
):
|
|
91
|
+
raise ValueError("finding file must be repository-relative")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class Summary:
|
|
96
|
+
by_severity: dict[str, int]
|
|
97
|
+
total: int
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class PostureReport:
|
|
102
|
+
report_version: str
|
|
103
|
+
scanner_version: str
|
|
104
|
+
scanned_at: str
|
|
105
|
+
scanned_path: str
|
|
106
|
+
findings: list[Finding] = field(default_factory=list)
|
|
107
|
+
summary: Summary = field(default_factory=lambda: empty_summary())
|
|
108
|
+
|
|
109
|
+
def to_json(self) -> str:
|
|
110
|
+
return json.dumps(asdict(self), indent=2, sort_keys=True) + "\n"
|
|
111
|
+
|
|
112
|
+
def to_sarif(self) -> dict[str, object]:
|
|
113
|
+
rule_ids = sorted(RULE_DESCRIPTORS)
|
|
114
|
+
rule_index = {rule_id: index for index, rule_id in enumerate(rule_ids)}
|
|
115
|
+
return {
|
|
116
|
+
"$schema": SARIF_SCHEMA,
|
|
117
|
+
"version": SARIF_VERSION,
|
|
118
|
+
"runs": [
|
|
119
|
+
{
|
|
120
|
+
"tool": {
|
|
121
|
+
"driver": {
|
|
122
|
+
"name": "AgentVeil Posture",
|
|
123
|
+
"informationUri": "https://github.com/agentveil-protocol/agentveil-posture",
|
|
124
|
+
"semanticVersion": "0.1.0",
|
|
125
|
+
"rules": [_sarif_rule(rule_id) for rule_id in rule_ids],
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
"results": [
|
|
129
|
+
_sarif_result(finding, rule_index[finding.rule_id])
|
|
130
|
+
for finding in self.findings
|
|
131
|
+
],
|
|
132
|
+
}
|
|
133
|
+
],
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def empty_summary() -> Summary:
|
|
138
|
+
return Summary(by_severity={severity: 0 for severity in SEVERITIES}, total=0)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def empty_report(scanned_path: str) -> PostureReport:
|
|
142
|
+
return build_report(scanned_path, [])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_report(scanned_path: str, findings: list[Finding]) -> PostureReport:
|
|
146
|
+
by_severity = {severity: 0 for severity in SEVERITIES}
|
|
147
|
+
for finding in findings:
|
|
148
|
+
by_severity[finding.severity] += 1
|
|
149
|
+
return PostureReport(
|
|
150
|
+
report_version=REPORT_VERSION,
|
|
151
|
+
scanner_version=SCANNER_VERSION,
|
|
152
|
+
scanned_at=utc_now_iso(),
|
|
153
|
+
scanned_path=scanned_path,
|
|
154
|
+
findings=findings,
|
|
155
|
+
summary=Summary(by_severity=by_severity, total=len(findings)),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _sarif_rule(rule_id: str) -> dict[str, object]:
|
|
160
|
+
descriptor = RULE_DESCRIPTORS[rule_id]
|
|
161
|
+
return {
|
|
162
|
+
"id": rule_id,
|
|
163
|
+
"shortDescription": {"text": descriptor["short"]},
|
|
164
|
+
"fullDescription": {"text": descriptor["full"]},
|
|
165
|
+
"helpUri": descriptor["help"],
|
|
166
|
+
"defaultConfiguration": {
|
|
167
|
+
"level": SEVERITY_TO_SARIF_LEVEL["high"],
|
|
168
|
+
},
|
|
169
|
+
"properties": {
|
|
170
|
+
"security-severity": SEVERITY_TO_SECURITY_SEVERITY["high"],
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _sarif_result(finding: Finding, rule_index: int) -> dict[str, object]:
|
|
176
|
+
physical_location: dict[str, object] = {
|
|
177
|
+
"artifactLocation": {"uri": finding.file.replace("\\", "/")}
|
|
178
|
+
}
|
|
179
|
+
if finding.line is not None:
|
|
180
|
+
physical_location["region"] = {"startLine": finding.line}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
"ruleId": finding.rule_id,
|
|
184
|
+
"ruleIndex": rule_index,
|
|
185
|
+
"level": SEVERITY_TO_SARIF_LEVEL[finding.severity],
|
|
186
|
+
"message": {"text": finding.message},
|
|
187
|
+
"locations": [
|
|
188
|
+
{
|
|
189
|
+
"physicalLocation": physical_location,
|
|
190
|
+
}
|
|
191
|
+
],
|
|
192
|
+
"partialFingerprints": {
|
|
193
|
+
"primaryLocationLineHash": _sarif_fingerprint(finding),
|
|
194
|
+
},
|
|
195
|
+
"properties": {
|
|
196
|
+
"severity": finding.severity,
|
|
197
|
+
"remediation": finding.remediation,
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _sarif_fingerprint(finding: Finding) -> str:
|
|
203
|
+
source = "\0".join(
|
|
204
|
+
[
|
|
205
|
+
finding.rule_id,
|
|
206
|
+
finding.file.replace("\\", "/"),
|
|
207
|
+
"" if finding.line is None else str(finding.line),
|
|
208
|
+
]
|
|
209
|
+
)
|
|
210
|
+
return hashlib.sha256(source.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Rule registry stubs for AgentVeil Posture v0.1."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agentveil_posture.rules.identity import scan_identity_private_key_unencrypted
|
|
6
|
+
from agentveil_posture.rules.manifest import is_agent_manifest, scan_manifest_rules
|
|
7
|
+
from agentveil_posture.rules.workflow import scan_workflow_rules
|
|
8
|
+
|
|
9
|
+
RULES = (
|
|
10
|
+
scan_identity_private_key_unencrypted,
|
|
11
|
+
scan_workflow_rules,
|
|
12
|
+
scan_manifest_rules,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"RULES",
|
|
17
|
+
"is_agent_manifest",
|
|
18
|
+
"scan_identity_private_key_unencrypted",
|
|
19
|
+
"scan_manifest_rules",
|
|
20
|
+
"scan_workflow_rules",
|
|
21
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Identity-related posture rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from agentveil_posture.report import Finding
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
HEADER_LIMIT_BYTES = 4096
|
|
11
|
+
PRIVATE_KEY_HEADERS = (
|
|
12
|
+
b"-----BEGIN PRIVATE KEY-----",
|
|
13
|
+
b"-----BEGIN RSA PRIVATE KEY-----",
|
|
14
|
+
b"-----BEGIN EC PRIVATE KEY-----",
|
|
15
|
+
b"-----BEGIN OPENSSH PRIVATE KEY-----",
|
|
16
|
+
)
|
|
17
|
+
ENCRYPTED_KEY_MARKERS = (
|
|
18
|
+
b"-----BEGIN ENCRYPTED PRIVATE KEY-----",
|
|
19
|
+
b"Proc-Type: 4,ENCRYPTED",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def scan_identity_private_key_unencrypted(root: Path, path: Path) -> Finding | None:
|
|
24
|
+
"""Detect unencrypted private-key headers without exposing key material.
|
|
25
|
+
|
|
26
|
+
Legacy PEM encryption markers are expected in the bounded header window,
|
|
27
|
+
matching standard PEM output.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
with path.open("rb") as handle:
|
|
31
|
+
header = handle.read(HEADER_LIMIT_BYTES)
|
|
32
|
+
except OSError:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
if any(marker in header for marker in ENCRYPTED_KEY_MARKERS):
|
|
36
|
+
return None
|
|
37
|
+
if not any(header.startswith(marker) for marker in PRIVATE_KEY_HEADERS):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
return Finding(
|
|
41
|
+
rule_id="identity.private_key_unencrypted",
|
|
42
|
+
severity="high",
|
|
43
|
+
file=path.relative_to(root).as_posix(),
|
|
44
|
+
line=1,
|
|
45
|
+
message="Unencrypted private key file appears present.",
|
|
46
|
+
remediation=(
|
|
47
|
+
"Remove the key from the repository, rotate it if exposed, store it "
|
|
48
|
+
"in a secret manager, and use encrypted private key material when "
|
|
49
|
+
"local keys are unavoidable."
|
|
50
|
+
),
|
|
51
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Agent/tool manifest posture rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from agentveil_posture.report import Finding
|
|
11
|
+
from agentveil_posture.rules.parsing import ParsedDocument, load_json_document, load_yaml_document
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
EXACT_MANIFEST_PATHS = {
|
|
15
|
+
".mcp.json",
|
|
16
|
+
".cursor/mcp.json",
|
|
17
|
+
"mcp.json",
|
|
18
|
+
"mcp_config.json",
|
|
19
|
+
}
|
|
20
|
+
MANIFEST_NAME_PATTERNS = (
|
|
21
|
+
"crew*.yaml",
|
|
22
|
+
"crew*.yml",
|
|
23
|
+
"autogen*.json",
|
|
24
|
+
"autogen*.yaml",
|
|
25
|
+
"autogen*.yml",
|
|
26
|
+
"langchain*.json",
|
|
27
|
+
"langchain*.yaml",
|
|
28
|
+
"langchain*.yml",
|
|
29
|
+
)
|
|
30
|
+
CREWAI_PATH_REGEX = re.compile(r"(^|/)crews/[^/]+/config/(agents|tasks)\.ya?ml$")
|
|
31
|
+
DIRECT_GITHUB_TOKEN_RE = re.compile(
|
|
32
|
+
r"\bsecrets\.(GITHUB_TOKEN|GH_TOKEN|GITHUB_PAT)\b"
|
|
33
|
+
r"|^\s*[\"']?(GITHUB_TOKEN|GH_TOKEN|GITHUB_PAT|github-token)[\"']?\s*[:=]"
|
|
34
|
+
r"|[\"'](GITHUB_TOKEN|GH_TOKEN|GITHUB_PAT|github-token)[\"']\s*:",
|
|
35
|
+
re.IGNORECASE | re.MULTILINE,
|
|
36
|
+
)
|
|
37
|
+
SHELL_KEY_RE = re.compile(r"(^|[_-])(shell|bash|command|terminal|subprocess)([_-]|$)", re.I)
|
|
38
|
+
SHELL_LINE_RE = re.compile(
|
|
39
|
+
r"(^|[\s,{])[\"']?(shell|bash|command|terminal|subprocess)[\"']?\s*[:=]",
|
|
40
|
+
re.I,
|
|
41
|
+
)
|
|
42
|
+
APPROVAL_KEY_RE = re.compile(r"(approval|approve|human)", re.I)
|
|
43
|
+
MAX_MANIFEST_SCAN_DEPTH = 100
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_agent_manifest(root: Path, path: Path) -> bool:
|
|
47
|
+
relative = path.relative_to(root).as_posix()
|
|
48
|
+
if relative in EXACT_MANIFEST_PATHS:
|
|
49
|
+
return True
|
|
50
|
+
if CREWAI_PATH_REGEX.search(relative):
|
|
51
|
+
return True
|
|
52
|
+
name = path.name.lower()
|
|
53
|
+
return any(fnmatch.fnmatch(name, pattern) for pattern in MANIFEST_NAME_PATTERNS)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def scan_manifest_rules(root: Path, path: Path) -> list[Finding]:
|
|
57
|
+
document = load_manifest(path)
|
|
58
|
+
if document is None:
|
|
59
|
+
return []
|
|
60
|
+
findings: list[Finding] = []
|
|
61
|
+
findings.extend(scan_manifest_direct_github_token(root, path, document))
|
|
62
|
+
findings.extend(scan_manifest_shell_without_approval(root, path, document))
|
|
63
|
+
return findings
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_manifest(path: Path) -> ParsedDocument | None:
|
|
67
|
+
if path.suffix.lower() == ".json":
|
|
68
|
+
return load_json_document(path)
|
|
69
|
+
if path.suffix.lower() in {".yaml", ".yml"}:
|
|
70
|
+
return load_yaml_document(path)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def scan_manifest_direct_github_token(
|
|
75
|
+
root: Path, path: Path, document: ParsedDocument
|
|
76
|
+
) -> list[Finding]:
|
|
77
|
+
line = _first_matching_line(document.lines, DIRECT_GITHUB_TOKEN_RE)
|
|
78
|
+
if line is None:
|
|
79
|
+
return []
|
|
80
|
+
return [
|
|
81
|
+
Finding(
|
|
82
|
+
rule_id="bypass.direct_github_token",
|
|
83
|
+
severity="high",
|
|
84
|
+
file=path.relative_to(root).as_posix(),
|
|
85
|
+
line=line,
|
|
86
|
+
message="Agent/tool manifest appears to expose direct GitHub token access.",
|
|
87
|
+
remediation=(
|
|
88
|
+
"Remove direct GitHub tokens from agent manifests, restrict token "
|
|
89
|
+
"permissions, and require approval for GitHub write or deploy paths."
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def scan_manifest_shell_without_approval(
|
|
96
|
+
root: Path, path: Path, document: ParsedDocument
|
|
97
|
+
) -> list[Finding]:
|
|
98
|
+
try:
|
|
99
|
+
has_shell = _has_shell_capability(document.data)
|
|
100
|
+
has_approval = _has_approval_signal(document.data)
|
|
101
|
+
except RecursionError:
|
|
102
|
+
return []
|
|
103
|
+
if not has_shell or has_approval:
|
|
104
|
+
return []
|
|
105
|
+
return [
|
|
106
|
+
Finding(
|
|
107
|
+
rule_id="tool.shell_without_approval",
|
|
108
|
+
severity="high",
|
|
109
|
+
file=path.relative_to(root).as_posix(),
|
|
110
|
+
line=_first_shell_line(document.lines),
|
|
111
|
+
message="Agent/tool manifest appears to enable shell execution without approval.",
|
|
112
|
+
remediation=(
|
|
113
|
+
"Require explicit approval or a narrow allowlist before enabling "
|
|
114
|
+
"shell, terminal, command, bash, or subprocess tools."
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _has_shell_capability(value: Any, depth: int = 0) -> bool:
|
|
121
|
+
if depth > MAX_MANIFEST_SCAN_DEPTH:
|
|
122
|
+
return False
|
|
123
|
+
if isinstance(value, dict):
|
|
124
|
+
for key, child in value.items():
|
|
125
|
+
if SHELL_KEY_RE.search(str(key)) and _value_enabled(child):
|
|
126
|
+
return True
|
|
127
|
+
if _has_shell_capability(child, depth + 1):
|
|
128
|
+
return True
|
|
129
|
+
if isinstance(value, list):
|
|
130
|
+
return any(_has_shell_capability(item, depth + 1) for item in value)
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _has_approval_signal(value: Any, depth: int = 0) -> bool:
|
|
135
|
+
if depth > MAX_MANIFEST_SCAN_DEPTH:
|
|
136
|
+
return False
|
|
137
|
+
if isinstance(value, dict):
|
|
138
|
+
for key, child in value.items():
|
|
139
|
+
if APPROVAL_KEY_RE.search(str(key)) and _value_enabled(child):
|
|
140
|
+
return True
|
|
141
|
+
if _has_approval_signal(child, depth + 1):
|
|
142
|
+
return True
|
|
143
|
+
if isinstance(value, list):
|
|
144
|
+
return any(_has_approval_signal(item, depth + 1) for item in value)
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _value_enabled(value: Any) -> bool:
|
|
149
|
+
if value is None or value is False:
|
|
150
|
+
return False
|
|
151
|
+
if isinstance(value, str):
|
|
152
|
+
return value.strip().lower() not in {"", "false", "no", "off", "none"}
|
|
153
|
+
if isinstance(value, (list, dict)):
|
|
154
|
+
return bool(value)
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _first_shell_line(lines: list[str]) -> int | None:
|
|
159
|
+
return _first_matching_line(lines, SHELL_LINE_RE)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _first_matching_line(lines: list[str], pattern: re.Pattern[str]) -> int | None:
|
|
163
|
+
for line_number, line in enumerate(lines, start=1):
|
|
164
|
+
if pattern.search(line):
|
|
165
|
+
return line_number
|
|
166
|
+
return None
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Bounded parsers for untrusted scanned configuration files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
MAX_TEXT_BYTES = 1_000_000
|
|
14
|
+
MAX_YAML_ALIAS_TOKENS = 25
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ParsedDocument:
|
|
19
|
+
data: Any
|
|
20
|
+
text: str
|
|
21
|
+
lines: list[str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_yaml_document(path: Path, max_bytes: int = MAX_TEXT_BYTES) -> ParsedDocument | None:
|
|
25
|
+
text = read_bounded_text(path, max_bytes=max_bytes)
|
|
26
|
+
if text is None or has_excessive_yaml_aliases(text):
|
|
27
|
+
return None
|
|
28
|
+
try:
|
|
29
|
+
data = yaml.safe_load(text)
|
|
30
|
+
except (yaml.YAMLError, RecursionError):
|
|
31
|
+
return None
|
|
32
|
+
return ParsedDocument(data=data, text=text, lines=text.splitlines())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_json_document(path: Path, max_bytes: int = MAX_TEXT_BYTES) -> ParsedDocument | None:
|
|
36
|
+
text = read_bounded_text(path, max_bytes=max_bytes)
|
|
37
|
+
if text is None:
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
data = json.loads(text)
|
|
41
|
+
except (json.JSONDecodeError, RecursionError):
|
|
42
|
+
return None
|
|
43
|
+
return ParsedDocument(data=data, text=text, lines=text.splitlines())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_bounded_text(path: Path, max_bytes: int = MAX_TEXT_BYTES) -> str | None:
|
|
47
|
+
try:
|
|
48
|
+
if path.stat().st_size > max_bytes:
|
|
49
|
+
return None
|
|
50
|
+
return path.read_text(encoding="utf-8-sig")
|
|
51
|
+
except (OSError, UnicodeDecodeError):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def has_excessive_yaml_aliases(text: str) -> bool:
|
|
56
|
+
aliases = 0
|
|
57
|
+
try:
|
|
58
|
+
for token in yaml.scan(text):
|
|
59
|
+
if isinstance(token, yaml.AliasToken):
|
|
60
|
+
aliases += 1
|
|
61
|
+
if aliases > MAX_YAML_ALIAS_TOKENS:
|
|
62
|
+
return True
|
|
63
|
+
except yaml.YAMLError:
|
|
64
|
+
return True
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def first_matching_line(lines: list[str], pattern) -> int | None:
|
|
69
|
+
for line_number, line in enumerate(lines, start=1):
|
|
70
|
+
if pattern.search(line):
|
|
71
|
+
return line_number
|
|
72
|
+
return None
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""GitHub workflow posture rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from agentveil_posture.report import Finding
|
|
9
|
+
from agentveil_posture.rules.parsing import (
|
|
10
|
+
MAX_TEXT_BYTES as MAX_WORKFLOW_BYTES,
|
|
11
|
+
MAX_YAML_ALIAS_TOKENS,
|
|
12
|
+
ParsedDocument,
|
|
13
|
+
load_yaml_document,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEPLOY_MARKER_RE = re.compile(
|
|
18
|
+
r"\b(deploy|deployment|release|kubectl)\b"
|
|
19
|
+
r"|\b(npm|pnpm|yarn|pypi|twine|poetry)\s+publish\b"
|
|
20
|
+
r"|\bterraform\s+apply\b"
|
|
21
|
+
r"|\bcloudformation\s+deploy\b"
|
|
22
|
+
r"|\bserverless\s+deploy\b",
|
|
23
|
+
re.IGNORECASE,
|
|
24
|
+
)
|
|
25
|
+
BUILD_CONFIG_EXCLUSIONS = re.compile(
|
|
26
|
+
r"--configuration\s+\w+"
|
|
27
|
+
r"|--framework\s+\w+"
|
|
28
|
+
r"|\brelease\s+notes\b"
|
|
29
|
+
r"|\brelease\s+candidate\b",
|
|
30
|
+
re.IGNORECASE,
|
|
31
|
+
)
|
|
32
|
+
APPROVAL_MARKERS = ("approval", "manual approval", "review", "protected environment")
|
|
33
|
+
PULL_REQUEST_TARGET_RE = re.compile(r"(^|\s)pull_request_target\s*:", re.MULTILINE)
|
|
34
|
+
PULL_REQUEST_TARGET_INLINE_RE = re.compile(
|
|
35
|
+
r"^\s*on\s*:\s*(\[.*pull_request_target.*\]|pull_request_target)\s*$",
|
|
36
|
+
re.MULTILINE,
|
|
37
|
+
)
|
|
38
|
+
PULL_REQUEST_TARGET_LIST_RE = re.compile(r"^\s*-\s*pull_request_target\s*$", re.MULTILINE)
|
|
39
|
+
DIRECT_GITHUB_TOKEN_RE = re.compile(
|
|
40
|
+
r"\bsecrets\.(GITHUB_TOKEN|GH_TOKEN|GITHUB_PAT)\b"
|
|
41
|
+
r"|^\s*(GITHUB_TOKEN|GH_TOKEN|GITHUB_PAT|github-token)\s*:",
|
|
42
|
+
re.IGNORECASE | re.MULTILINE,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def scan_workflow_rules(root: Path, path: Path) -> list[Finding]:
|
|
47
|
+
document = load_workflow(path)
|
|
48
|
+
if document is None:
|
|
49
|
+
return []
|
|
50
|
+
findings: list[Finding] = []
|
|
51
|
+
findings.extend(scan_workflow_direct_github_token(root, path, document))
|
|
52
|
+
findings.extend(scan_workflow_deploy_without_approval(root, path, document))
|
|
53
|
+
findings.extend(scan_workflow_pull_request_target_secrets_risk(root, path, document))
|
|
54
|
+
return findings
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def scan_workflow_direct_github_token(
|
|
58
|
+
root: Path, path: Path, document: ParsedDocument
|
|
59
|
+
) -> list[Finding]:
|
|
60
|
+
line = _first_matching_line(document.lines, DIRECT_GITHUB_TOKEN_RE)
|
|
61
|
+
if line is None:
|
|
62
|
+
return []
|
|
63
|
+
return [
|
|
64
|
+
Finding(
|
|
65
|
+
rule_id="bypass.direct_github_token",
|
|
66
|
+
severity="high",
|
|
67
|
+
file=path.relative_to(root).as_posix(),
|
|
68
|
+
line=line,
|
|
69
|
+
message="Workflow appears to expose direct GitHub token access.",
|
|
70
|
+
remediation=(
|
|
71
|
+
"Restrict GitHub token permissions, avoid passing direct write "
|
|
72
|
+
"tokens to agent-controlled steps, and require approval for "
|
|
73
|
+
"GitHub write or deploy paths."
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def scan_workflow_deploy_without_approval(
|
|
80
|
+
root: Path, path: Path, document: ParsedDocument
|
|
81
|
+
) -> list[Finding]:
|
|
82
|
+
deploy_line = _first_deploy_line(document.lines)
|
|
83
|
+
if deploy_line is None or _has_approval_signal(document.text):
|
|
84
|
+
return []
|
|
85
|
+
return [
|
|
86
|
+
Finding(
|
|
87
|
+
rule_id="workflow.deploy_without_approval",
|
|
88
|
+
severity="high",
|
|
89
|
+
file=path.relative_to(root).as_posix(),
|
|
90
|
+
line=deploy_line,
|
|
91
|
+
message="Deployment workflow appears to run without an approval gate.",
|
|
92
|
+
remediation=(
|
|
93
|
+
"Add a protected GitHub environment or explicit manual approval "
|
|
94
|
+
"before production deploy, release, or publish steps."
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def scan_workflow_pull_request_target_secrets_risk(
|
|
101
|
+
root: Path, path: Path, document: ParsedDocument
|
|
102
|
+
) -> list[Finding]:
|
|
103
|
+
trigger_line = _pull_request_target_line(document.lines)
|
|
104
|
+
if trigger_line is None or not _has_pull_request_target_risk(document.text):
|
|
105
|
+
return []
|
|
106
|
+
return [
|
|
107
|
+
Finding(
|
|
108
|
+
rule_id="workflow.pull_request_target_secrets_risk",
|
|
109
|
+
severity="high",
|
|
110
|
+
file=path.relative_to(root).as_posix(),
|
|
111
|
+
line=trigger_line,
|
|
112
|
+
message=(
|
|
113
|
+
"pull_request_target workflow appears to combine privileged PR "
|
|
114
|
+
"context with checkout, shell execution, or secrets access."
|
|
115
|
+
),
|
|
116
|
+
remediation=(
|
|
117
|
+
"Use pull_request for untrusted checks, avoid checking out fork "
|
|
118
|
+
"code in privileged workflows, and isolate any secret-bearing jobs."
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_workflow(path: Path) -> ParsedDocument | None:
|
|
125
|
+
return load_yaml_document(path, max_bytes=MAX_WORKFLOW_BYTES)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _first_deploy_line(lines: list[str]) -> int | None:
|
|
129
|
+
for line_number, line in enumerate(lines, start=1):
|
|
130
|
+
if BUILD_CONFIG_EXCLUSIONS.search(line):
|
|
131
|
+
continue
|
|
132
|
+
if DEPLOY_MARKER_RE.search(line):
|
|
133
|
+
return line_number
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _first_matching_line(lines: list[str], pattern: re.Pattern[str]) -> int | None:
|
|
138
|
+
for line_number, line in enumerate(lines, start=1):
|
|
139
|
+
if pattern.search(line):
|
|
140
|
+
return line_number
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _has_approval_signal(text: str) -> bool:
|
|
145
|
+
normalized = text.lower()
|
|
146
|
+
if re.search(r"^\s*environment\s*:", text, flags=re.MULTILINE):
|
|
147
|
+
return True
|
|
148
|
+
return any(marker in normalized for marker in APPROVAL_MARKERS)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _pull_request_target_line(lines: list[str]) -> int | None:
|
|
152
|
+
for line_number, line in enumerate(lines, start=1):
|
|
153
|
+
if line.lstrip().startswith("#"):
|
|
154
|
+
continue
|
|
155
|
+
if "pull_request_target" in line:
|
|
156
|
+
return line_number
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _has_pull_request_target_risk(text: str) -> bool:
|
|
161
|
+
if not _has_pull_request_target_trigger(text):
|
|
162
|
+
return False
|
|
163
|
+
normalized = text.lower()
|
|
164
|
+
return (
|
|
165
|
+
"actions/checkout" in normalized
|
|
166
|
+
or "${{ secrets." in normalized
|
|
167
|
+
or re.search(r"^\s*-?\s*run\s*:", text, flags=re.MULTILINE) is not None
|
|
168
|
+
or "github.event.pull_request.head" in normalized
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _has_pull_request_target_trigger(text: str) -> bool:
|
|
173
|
+
text = _without_comment_lines(text)
|
|
174
|
+
return (
|
|
175
|
+
PULL_REQUEST_TARGET_RE.search(text) is not None
|
|
176
|
+
or PULL_REQUEST_TARGET_INLINE_RE.search(text) is not None
|
|
177
|
+
or PULL_REQUEST_TARGET_LIST_RE.search(text) is not None
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _without_comment_lines(text: str) -> str:
|
|
182
|
+
return "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Static scan orchestrator for AgentVeil Posture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from agentveil_posture.report import Finding, PostureReport, build_report
|
|
9
|
+
from agentveil_posture.rules import (
|
|
10
|
+
is_agent_manifest,
|
|
11
|
+
scan_identity_private_key_unencrypted,
|
|
12
|
+
scan_manifest_rules,
|
|
13
|
+
scan_workflow_rules,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ScanError(Exception):
|
|
18
|
+
"""Raised when the requested scan path cannot be scanned safely."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def scan_path(path: Path) -> PostureReport:
|
|
22
|
+
"""Run a static, read-only scan for the currently implemented v0.1 rules."""
|
|
23
|
+
root = path.resolve()
|
|
24
|
+
if not root.exists():
|
|
25
|
+
raise ScanError(f"scan path does not exist: {path}")
|
|
26
|
+
if not root.is_dir():
|
|
27
|
+
raise ScanError(f"scan path is not a directory: {path}")
|
|
28
|
+
|
|
29
|
+
findings: list[Finding] = []
|
|
30
|
+
for candidate in _iter_regular_files(root):
|
|
31
|
+
finding = scan_identity_private_key_unencrypted(root, candidate)
|
|
32
|
+
if finding is not None:
|
|
33
|
+
findings.append(finding)
|
|
34
|
+
if _is_github_workflow(root, candidate):
|
|
35
|
+
findings.extend(scan_workflow_rules(root, candidate))
|
|
36
|
+
if is_agent_manifest(root, candidate):
|
|
37
|
+
findings.extend(scan_manifest_rules(root, candidate))
|
|
38
|
+
|
|
39
|
+
return build_report(str(root), findings)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _iter_regular_files(root: Path) -> list[Path]:
|
|
43
|
+
paths: list[Path] = []
|
|
44
|
+
for dirpath, _dirnames, filenames in os.walk(str(root), followlinks=False):
|
|
45
|
+
for filename in filenames:
|
|
46
|
+
path = Path(dirpath) / filename
|
|
47
|
+
if path.is_symlink():
|
|
48
|
+
continue
|
|
49
|
+
if path.is_file():
|
|
50
|
+
paths.append(path)
|
|
51
|
+
return sorted(paths)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_github_workflow(root: Path, path: Path) -> bool:
|
|
55
|
+
relative = path.relative_to(root)
|
|
56
|
+
return (
|
|
57
|
+
len(relative.parts) >= 3
|
|
58
|
+
and relative.parts[0] == ".github"
|
|
59
|
+
and relative.parts[1] == "workflows"
|
|
60
|
+
and path.suffix.lower() in {".yml", ".yaml"}
|
|
61
|
+
)
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentveil-posture
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pre-deployment posture checks for risky AI agent capabilities.
|
|
5
|
+
Author: AgentVeil Protocol contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://agentveil.dev
|
|
8
|
+
Project-URL: Repository, https://github.com/agentveil-protocol/agentveil-posture
|
|
9
|
+
Project-URL: Issues, https://github.com/agentveil-protocol/agentveil-posture/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/agentveil-protocol/agentveil-posture#readme
|
|
11
|
+
Keywords: security,agent,mcp,posture,github-actions,ai-agents
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: PyYAML<7,>=6.0.1
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# AgentVeil Posture
|
|
31
|
+
|
|
32
|
+
<p align="center">
|
|
33
|
+
<img src="docs/agentveil-posture-logo.png" alt="AgentVeil Posture logo" width="180">
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
[](https://pypi.org/project/agentveil-posture/)
|
|
38
|
+
[](https://github.com/agentveil-protocol/agentveil-posture/actions/workflows/posture-self-test.yml)
|
|
39
|
+
[](https://www.python.org/downloads/)
|
|
40
|
+
[](https://github.com/agentveil-protocol/agentveil-posture/stargazers)
|
|
41
|
+
[](#use-as-a-github-action)
|
|
42
|
+
[](#hard-constraints)
|
|
43
|
+
|
|
44
|
+
**Pre-deployment posture check for AI agents. Find risky capabilities before they become production incidents.**
|
|
45
|
+
|
|
46
|
+
`agentveil-posture` is a pre-deployment, static, local-only scanner that flags
|
|
47
|
+
risky AI-agent and GitHub-workflow posture issues. No telemetry, no network
|
|
48
|
+
calls, no project code execution. v0.1 ships five high-severity GitHub-focused
|
|
49
|
+
rules.
|
|
50
|
+
|
|
51
|
+
[Quick Start](#quick-start) |
|
|
52
|
+
[What a finding looks like](#what-a-finding-looks-like) |
|
|
53
|
+
[Detection scope](#detection-scope-v01) |
|
|
54
|
+
[GitHub Action](#use-as-a-github-action) |
|
|
55
|
+
[Why this exists](#why-this-exists)
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install agentveil-posture
|
|
63
|
+
agentveil posture scan --path . --output report.json
|
|
64
|
+
cat report.json
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
That is the whole flow. The scanner is read-only: it does not modify your
|
|
68
|
+
files, run your code, or send data over the network.
|
|
69
|
+
|
|
70
|
+
To fail CI when findings meet a threshold, add `--fail-on`:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
agentveil posture scan --path . --output report.json --fail-on high
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## What a Finding Looks Like
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"rule_id": "workflow.deploy_without_approval",
|
|
81
|
+
"severity": "high",
|
|
82
|
+
"file": ".github/workflows/deploy.yml",
|
|
83
|
+
"line": 12,
|
|
84
|
+
"message": "Deployment workflow appears to run without an approval gate.",
|
|
85
|
+
"remediation": "Add a protected GitHub environment or explicit manual approval before production deploy, release, or publish steps."
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Every finding contains rule ID, severity, repository-relative file path, line
|
|
90
|
+
number when available, redacted message, and remediation pointer. Raw secrets,
|
|
91
|
+
command bodies, and key material never appear in the report.
|
|
92
|
+
|
|
93
|
+
## Detection Scope (v0.1)
|
|
94
|
+
|
|
95
|
+
All v0.1 rules are reported as `high` severity.
|
|
96
|
+
|
|
97
|
+
| Rule | What it flags |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `bypass.direct_github_token` | Direct GitHub PAT/token references in workflows or agent manifests |
|
|
100
|
+
| `workflow.deploy_without_approval` | Deploy/release/publish steps without an approval gate |
|
|
101
|
+
| `workflow.pull_request_target_secrets_risk` | `pull_request_target` workflows that combine privileged context with checkout, run, or secrets |
|
|
102
|
+
| `tool.shell_without_approval` | Agent tool manifests that enable shell execution without an approval flag |
|
|
103
|
+
| `identity.private_key_unencrypted` | Unencrypted PEM private key files committed to the repo |
|
|
104
|
+
|
|
105
|
+
## Install
|
|
106
|
+
|
|
107
|
+
<details>
|
|
108
|
+
<summary><b>From PyPI (recommended)</b></summary>
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pip install agentveil-posture
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
</details>
|
|
115
|
+
|
|
116
|
+
<details>
|
|
117
|
+
<summary><b>From GitHub release</b></summary>
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
pip install git+https://github.com/agentveil-protocol/agentveil-posture@v0.1.0
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
</details>
|
|
124
|
+
|
|
125
|
+
<details>
|
|
126
|
+
<summary><b>From source (development)</b></summary>
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
git clone https://github.com/agentveil-protocol/agentveil-posture
|
|
130
|
+
cd agentveil-posture
|
|
131
|
+
pip install -e .
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
</details>
|
|
135
|
+
|
|
136
|
+
## Use as a GitHub Action
|
|
137
|
+
|
|
138
|
+
Use the action from the same repository:
|
|
139
|
+
|
|
140
|
+
```yaml
|
|
141
|
+
- uses: agentveil-protocol/agentveil-posture@v0.1.0
|
|
142
|
+
with:
|
|
143
|
+
path: "."
|
|
144
|
+
output: agentveil-posture-report.json
|
|
145
|
+
fail-on: high
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The action requires Python 3.10 or newer on the runner. It writes the report
|
|
149
|
+
path to the `report` output and does not upload data to AgentVeil. Omit
|
|
150
|
+
`fail-on` to keep review-only behavior.
|
|
151
|
+
|
|
152
|
+
For GitHub Code Scanning, write SARIF and upload it with CodeQL:
|
|
153
|
+
|
|
154
|
+
```yaml
|
|
155
|
+
- uses: agentveil-protocol/agentveil-posture@v0.1.0
|
|
156
|
+
with:
|
|
157
|
+
path: "."
|
|
158
|
+
output: agentveil-posture.sarif
|
|
159
|
+
format: sarif
|
|
160
|
+
|
|
161
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
162
|
+
with:
|
|
163
|
+
sarif_file: agentveil-posture.sarif
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Pre-commit Hook
|
|
167
|
+
|
|
168
|
+
Run AgentVeil Posture as a [pre-commit](https://pre-commit.com) hook to catch
|
|
169
|
+
posture issues before they reach the remote.
|
|
170
|
+
|
|
171
|
+
Add to your `.pre-commit-config.yaml`:
|
|
172
|
+
|
|
173
|
+
```yaml
|
|
174
|
+
repos:
|
|
175
|
+
- repo: https://github.com/agentveil-protocol/agentveil-posture
|
|
176
|
+
rev: v0.1.0
|
|
177
|
+
hooks:
|
|
178
|
+
- id: agentveil-posture
|
|
179
|
+
args: ["--fail-on", "high"]
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Then install:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
pre-commit install
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The hook generates `agentveil-posture-report.json` on every commit. Omit
|
|
189
|
+
`args` for review-only behavior, or use `--fail-on` to block commits when
|
|
190
|
+
findings meet the selected threshold.
|
|
191
|
+
|
|
192
|
+
## Triaging Findings
|
|
193
|
+
|
|
194
|
+
`agentveil-posture` flags **posture surfaces**: places where an AI agent or
|
|
195
|
+
workflow has direct capability to do something risky. Most findings are
|
|
196
|
+
**review items**, not incidents:
|
|
197
|
+
|
|
198
|
+
- **`bypass.direct_github_token`** commonly appears on stale-bots,
|
|
199
|
+
release-bots, CI publish steps, and label-management workflows that
|
|
200
|
+
legitimately use the auto-injected `secrets.GITHUB_TOKEN`. The rule fires
|
|
201
|
+
by design: the workflow holds direct GitHub write capability and that is a
|
|
202
|
+
posture surface worth surfacing, even when expected.
|
|
203
|
+
- **`workflow.deploy_without_approval`** may flag deploy paths that have
|
|
204
|
+
approval mechanisms the static scanner cannot see, such as manual job
|
|
205
|
+
dispatch, branch protection, or external reviewer chains. Verify against
|
|
206
|
+
your actual approval flow before treating as incident.
|
|
207
|
+
- **`workflow.pull_request_target_secrets_risk`** flags risky combinations,
|
|
208
|
+
but some `pull_request_target` workflows are correctly scoped to label-only
|
|
209
|
+
or metadata-only operations. Re-check the actual job content.
|
|
210
|
+
- **`tool.shell_without_approval`** flags inline shell capability
|
|
211
|
+
declarations. Tools referenced by name, such as `search_tool` in CrewAI,
|
|
212
|
+
are not detected; only literal `shell:` or `bash:` keys are.
|
|
213
|
+
- **`identity.private_key_unencrypted`** is the most reliably actionable
|
|
214
|
+
finding: committed unencrypted private keys are usually real issues.
|
|
215
|
+
|
|
216
|
+
Use posture-check to surface review items for human triage, not to auto-block
|
|
217
|
+
CI or replace SAST/secret-scanning tools.
|
|
218
|
+
|
|
219
|
+
## Why This Exists
|
|
220
|
+
|
|
221
|
+
AI agents increasingly touch production credentials, deploy workflows, and
|
|
222
|
+
developer infrastructure. AgentVeil Posture is the first step: find risky
|
|
223
|
+
capabilities before deployment and before they become incidents.
|
|
224
|
+
|
|
225
|
+
```text
|
|
226
|
+
+----------+ +----------+ +----------+
|
|
227
|
+
| FIND | | DECIDE | | PROVE |
|
|
228
|
+
| risky | ---> | what is | ---> | what |
|
|
229
|
+
| caps | | allowed | | happened |
|
|
230
|
+
+----------+ +----------+ +----------+
|
|
231
|
+
you are here roadmap roadmap
|
|
232
|
+
v0.1 Posture
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
| | Posture does | Posture does not |
|
|
236
|
+
|---|---|---|
|
|
237
|
+
| Scope | Static analysis and posture risk patterns | Approval, blocking, or execution of agent actions |
|
|
238
|
+
| Effects | Read-only file inspection | Code execution, network calls, or file mutation |
|
|
239
|
+
| Output | Redacted JSON findings | Secret values, command bodies, or key bytes |
|
|
240
|
+
|
|
241
|
+
For the broader AgentVeil project, see [agentveil.dev](https://agentveil.dev).
|
|
242
|
+
|
|
243
|
+
## Hard Constraints
|
|
244
|
+
|
|
245
|
+
The scanner is designed to be:
|
|
246
|
+
|
|
247
|
+
- **offline**: no network calls
|
|
248
|
+
- **telemetry-free**: no usage data collected
|
|
249
|
+
- **read-only**: does not modify scanned files
|
|
250
|
+
- **static-only**: does not execute scanned project code
|
|
251
|
+
- **secret-safe**: reports only redacted findings
|
|
252
|
+
|
|
253
|
+
Private-key checks use file metadata and bounded header sniffing only.
|
|
254
|
+
|
|
255
|
+
## Dependency Policy
|
|
256
|
+
|
|
257
|
+
Runtime dependencies are intentionally minimal:
|
|
258
|
+
|
|
259
|
+
- Python `>=3.10`
|
|
260
|
+
- `PyYAML>=6.0.1,<7`
|
|
261
|
+
|
|
262
|
+
Additional runtime dependencies require explicit justification in
|
|
263
|
+
[PLAN.md](PLAN.md).
|
|
264
|
+
|
|
265
|
+
## Known Limitations
|
|
266
|
+
|
|
267
|
+
`agentveil-posture` v0.1 is a best-effort heuristic scanner, not an exhaustive
|
|
268
|
+
security audit.
|
|
269
|
+
|
|
270
|
+
- Some rules may produce false positives or false negatives.
|
|
271
|
+
- Oversized, unreadable, or malformed inputs may be skipped without per-file
|
|
272
|
+
skip reasons.
|
|
273
|
+
- YAML parsing is bounded, but carefully crafted YAML within the v0.1 alias
|
|
274
|
+
limit can still consume parser memory.
|
|
275
|
+
- The repository includes an intentional synthetic PEM-shaped fixture for
|
|
276
|
+
scanner tests. It is not a real private key.
|
|
277
|
+
|
|
278
|
+
## Community
|
|
279
|
+
|
|
280
|
+
- [Star this repo](https://github.com/agentveil-protocol/agentveil-posture/stargazers)
|
|
281
|
+
if Posture helps your team.
|
|
282
|
+
- [Open an issue](https://github.com/agentveil-protocol/agentveil-posture/issues)
|
|
283
|
+
for bugs, false positives, or rule suggestions.
|
|
284
|
+
- See [PLAN.md](PLAN.md) for the v0.1 spec, schema details, and v0.2 backlog.
|
|
285
|
+
|
|
286
|
+
## License
|
|
287
|
+
|
|
288
|
+
MIT. See [LICENSE](LICENSE).
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
Part of the [AgentVeil project](https://agentveil.dev): action control for
|
|
293
|
+
autonomous agents.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
agentveil_posture/__init__.py,sha256=Dgw7FEUaYoFV0C4WHG2n5w5SYQoIIbIOiA8a-zFsABI,91
|
|
2
|
+
agentveil_posture/cli.py,sha256=kWraymEt1oCQXPSs63nbl_cXcTfEnn8wxSfjcthxwB4,2883
|
|
3
|
+
agentveil_posture/report.py,sha256=O6WX-aCQsPbQs_XWxLzELe9VW6IsLNRUuQl_03NWDsE,7032
|
|
4
|
+
agentveil_posture/scanner.py,sha256=v4zhZAHi-f-ChtUeaTn8l37bd2etIvB74FUQdU-arlc,1967
|
|
5
|
+
agentveil_posture/rules/__init__.py,sha256=hXCYfGaAhdYVAjj1MmSD5EG8gRssRiftQmGv9bGqTNQ,581
|
|
6
|
+
agentveil_posture/rules/identity.py,sha256=Ja--Wi0pYpUkU-itZhA1LWZxqfRUKsOF2CFsnHrzJYo,1544
|
|
7
|
+
agentveil_posture/rules/manifest.py,sha256=ntzVosGBk4NHuhcrlSlFBhsGiqiW6F8Ug4dU1_dYoBc,5441
|
|
8
|
+
agentveil_posture/rules/parsing.py,sha256=JjJMCpuZOpQlc17zDjdn3t2EaliZT2CQlF-PE0O2jl8,1975
|
|
9
|
+
agentveil_posture/rules/workflow.py,sha256=D_XcjNPtC2d8ytRPlQoC94naAA7Sm9LOJV5MbcF056c,6165
|
|
10
|
+
agentveil_posture-0.1.0.dist-info/licenses/LICENSE,sha256=tINj2Q4xPZbm90mBF98TA4FWxlpmvcKGrOh8vLF0f64,1088
|
|
11
|
+
agentveil_posture-0.1.0.dist-info/METADATA,sha256=ihh3rHGupC_xRHDvNubSRPbqPVVdni7rZWT9pXXDgQ8,10515
|
|
12
|
+
agentveil_posture-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
agentveil_posture-0.1.0.dist-info/entry_points.txt,sha256=n7anDKPyXHTY6khIeNX3v53bu5BvcNAVaa7VCUxv4hA,57
|
|
14
|
+
agentveil_posture-0.1.0.dist-info/top_level.txt,sha256=O9TZzoSTHqDrSDHjG9za84UBuqgDfOoIpPrPpUoGras,18
|
|
15
|
+
agentveil_posture-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AgentVeil Protocol contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agentveil_posture
|