agentpipe-scan 0.5.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.
- agentpipe/__init__.py +11 -0
- agentpipe/cli.py +164 -0
- agentpipe/detect.py +589 -0
- agentpipe/findings.py +19 -0
- agentpipe/fingerprints.py +159 -0
- agentpipe/local.py +257 -0
- agentpipe/parse.py +113 -0
- agentpipe/prove.py +150 -0
- agentpipe/remote.py +142 -0
- agentpipe/report.py +86 -0
- agentpipe_scan-0.5.0.dist-info/METADATA +126 -0
- agentpipe_scan-0.5.0.dist-info/RECORD +16 -0
- agentpipe_scan-0.5.0.dist-info/WHEEL +5 -0
- agentpipe_scan-0.5.0.dist-info/entry_points.txt +2 -0
- agentpipe_scan-0.5.0.dist-info/licenses/LICENSE +21 -0
- agentpipe_scan-0.5.0.dist-info/top_level.txt +1 -0
agentpipe/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""agentpipe — scan agentic CI for untrusted-text -> agent -> secret chains."""
|
|
2
|
+
# Single source of truth is pyproject; read from installed metadata so
|
|
3
|
+
# `agentpipe --version` never drifts from the released package.
|
|
4
|
+
try:
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version as _v
|
|
6
|
+
try:
|
|
7
|
+
__version__ = _v("agentpipe-scan")
|
|
8
|
+
except PackageNotFoundError:
|
|
9
|
+
__version__ = "0.0.0+source"
|
|
10
|
+
except Exception: # pragma: no cover
|
|
11
|
+
__version__ = "0.0.0+source"
|
agentpipe/cli.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""agentpipe — untrusted GitHub text → CI agent → secret/write."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from . import prove, remote, local
|
|
10
|
+
from .detect import scan_tree
|
|
11
|
+
from .report import render
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _resolve(target: str, token: str | None) -> tuple[Path, str] | None:
|
|
15
|
+
"""Local path or remote repo → (root, label). Prints errors, None on fail."""
|
|
16
|
+
if remote.is_remote_target(target):
|
|
17
|
+
try:
|
|
18
|
+
root, ref = remote.fetch_repo(target, token=token)
|
|
19
|
+
except remote.RemoteError as e:
|
|
20
|
+
print(f"agentpipe: {e}", file=sys.stderr)
|
|
21
|
+
return None
|
|
22
|
+
print(f"agentpipe: remote scan {ref} — public files only", file=sys.stderr)
|
|
23
|
+
return root, target
|
|
24
|
+
root = Path(target)
|
|
25
|
+
if not root.exists():
|
|
26
|
+
print(f"agentpipe: {root} not found "
|
|
27
|
+
f"(for remote: agentpipe scan github.com/owner/repo)", file=sys.stderr)
|
|
28
|
+
return None
|
|
29
|
+
return root, target
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _scan_one(root: Path) -> tuple[list, list[str]]:
|
|
33
|
+
errors: list[str] = []
|
|
34
|
+
findings = scan_tree(root, errors=errors)
|
|
35
|
+
for e in errors:
|
|
36
|
+
print(f"agentpipe: warning: {e}", file=sys.stderr)
|
|
37
|
+
return findings, errors
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _fail_code(findings, fail_on: str) -> int:
|
|
41
|
+
highs = any(f.severity == "high" for f in findings)
|
|
42
|
+
meds = any(f.severity == "medium" for f in findings)
|
|
43
|
+
if fail_on == "high" and highs:
|
|
44
|
+
return 1
|
|
45
|
+
if fail_on == "medium" and (highs or meds):
|
|
46
|
+
return 1
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _scan_org(target: str, token: str | None, max_repos: int,
|
|
51
|
+
json_out: bool) -> int:
|
|
52
|
+
org = remote.org_name(target)
|
|
53
|
+
try:
|
|
54
|
+
repos = remote.list_org_repos(org, token=token, max_repos=max_repos)
|
|
55
|
+
except remote.RemoteError as e:
|
|
56
|
+
print(f"agentpipe: {e}", file=sys.stderr)
|
|
57
|
+
return 2
|
|
58
|
+
print(f"agentpipe: org {org} — {len(repos)} public repos queued",
|
|
59
|
+
file=sys.stderr)
|
|
60
|
+
agg: dict[str, list] = {}
|
|
61
|
+
any_high = False
|
|
62
|
+
for full in repos:
|
|
63
|
+
try:
|
|
64
|
+
root, ref = remote.fetch_repo(f"github.com/{full}", token=token)
|
|
65
|
+
except remote.RemoteError as e:
|
|
66
|
+
print(f"agentpipe: {full}: {e}", file=sys.stderr)
|
|
67
|
+
continue
|
|
68
|
+
findings, _err = _scan_one(root)
|
|
69
|
+
agg[full] = findings
|
|
70
|
+
sev = [f for f in findings if f.severity]
|
|
71
|
+
if sev:
|
|
72
|
+
h = sum(1 for f in sev if f.severity == "high")
|
|
73
|
+
m = sum(1 for f in sev if f.severity == "medium")
|
|
74
|
+
any_high = any_high or h > 0
|
|
75
|
+
print(f" {full}: {h} high {m} medium "
|
|
76
|
+
f"({', '.join(sorted({f.id for f in sev}))})", file=sys.stderr)
|
|
77
|
+
if json_out:
|
|
78
|
+
print(json.dumps({r: [f.to_dict() for f in fs] for r, fs in agg.items()},
|
|
79
|
+
indent=2))
|
|
80
|
+
else:
|
|
81
|
+
print(f"\n== {org} org scan ==")
|
|
82
|
+
for full, fs in agg.items():
|
|
83
|
+
sev = [f for f in fs if f.severity]
|
|
84
|
+
if not sev:
|
|
85
|
+
continue
|
|
86
|
+
print(f"\n### {full}")
|
|
87
|
+
print(render(sev + [f for f in fs if not f.severity]))
|
|
88
|
+
return 1 if any_high else 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main(argv: list[str] | None = None) -> int:
|
|
92
|
+
p = argparse.ArgumentParser(
|
|
93
|
+
prog="agentpipe",
|
|
94
|
+
description="Find CI agents that eat untrusted GitHub events and hold keys.")
|
|
95
|
+
p.add_argument("--version", action="version", version=f"agentpipe {__version__}")
|
|
96
|
+
sub = p.add_subparsers(dest="cmd")
|
|
97
|
+
sc = sub.add_parser("scan", help="scan a repo, github.com/owner/repo or github.com/org")
|
|
98
|
+
sc.add_argument("path", nargs="?", default=".")
|
|
99
|
+
sc.add_argument("--json", action="store_true")
|
|
100
|
+
sc.add_argument("--token", default=None,
|
|
101
|
+
help="GitHub token for remote scan (or GITHUB_TOKEN/GH_TOKEN env)")
|
|
102
|
+
sc.add_argument("--max-repos", type=int, default=50,
|
|
103
|
+
help="org scan: cap on repos fetched")
|
|
104
|
+
sc.add_argument("--fail-on", default="high",
|
|
105
|
+
choices=["never", "high", "medium"],
|
|
106
|
+
help="exit 1 if findings at this severity exist")
|
|
107
|
+
pv = sub.add_parser("prove", help="generate a harmless canary kit for each finding")
|
|
108
|
+
pv.add_argument("path", nargs="?", default=".")
|
|
109
|
+
pv.add_argument("--token", default=None)
|
|
110
|
+
pv.add_argument("--json", action="store_true")
|
|
111
|
+
lc = sub.add_parser("local", help="audit agent configs on this machine (MCP, permissions, hooks)")
|
|
112
|
+
lc.add_argument("--root", default=None,
|
|
113
|
+
help="home dir to audit (default: your $HOME)")
|
|
114
|
+
lc.add_argument("--cwd", default=".",
|
|
115
|
+
help="project dir to audit (default: .)")
|
|
116
|
+
lc.add_argument("--json", action="store_true")
|
|
117
|
+
args = p.parse_args(sys.argv[1:] if argv is None else argv)
|
|
118
|
+
if args.cmd not in ("scan", "prove", "local"):
|
|
119
|
+
p.print_help()
|
|
120
|
+
return 2
|
|
121
|
+
|
|
122
|
+
if args.cmd == "local":
|
|
123
|
+
root = Path(args.root).expanduser() if args.root else Path.home()
|
|
124
|
+
cwd = Path(args.cwd).resolve()
|
|
125
|
+
errors: list[str] = []
|
|
126
|
+
findings = local.scan_local(root, cwd, errors=errors)
|
|
127
|
+
for e in errors:
|
|
128
|
+
print(f"agentpipe: warning: {e}", file=sys.stderr)
|
|
129
|
+
if args.json:
|
|
130
|
+
print(json.dumps([f.to_dict() for f in findings], indent=2))
|
|
131
|
+
else:
|
|
132
|
+
print(render(findings))
|
|
133
|
+
return _fail_code(findings, "high")
|
|
134
|
+
|
|
135
|
+
if args.cmd == "prove":
|
|
136
|
+
r = _resolve(args.path, args.token)
|
|
137
|
+
if r is None:
|
|
138
|
+
return 2
|
|
139
|
+
root, label = r
|
|
140
|
+
findings, _ = _scan_one(root)
|
|
141
|
+
if args.json:
|
|
142
|
+
print(json.dumps([prove.build(f) for f in findings
|
|
143
|
+
if f.severity in ("high", "medium")], indent=2))
|
|
144
|
+
else:
|
|
145
|
+
print(prove.render_kit(findings, label))
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
if remote.is_org_target(args.path) and not remote.is_remote_target(args.path):
|
|
149
|
+
return _scan_org(args.path, args.token, args.max_repos, args.json)
|
|
150
|
+
|
|
151
|
+
r = _resolve(args.path, args.token)
|
|
152
|
+
if r is None:
|
|
153
|
+
return 2
|
|
154
|
+
root, _label = r
|
|
155
|
+
findings, _ = _scan_one(root)
|
|
156
|
+
if args.json:
|
|
157
|
+
print(json.dumps([f.to_dict() for f in findings], indent=2))
|
|
158
|
+
else:
|
|
159
|
+
print(render(findings))
|
|
160
|
+
return _fail_code(findings, args.fail_on)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
raise SystemExit(main())
|