devguard 0.2.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.
- devguard/INTEGRATION_SUMMARY.md +121 -0
- devguard/__init__.py +3 -0
- devguard/__main__.py +6 -0
- devguard/checkers/__init__.py +41 -0
- devguard/checkers/api_usage.py +523 -0
- devguard/checkers/aws_cost.py +331 -0
- devguard/checkers/aws_iam.py +284 -0
- devguard/checkers/base.py +25 -0
- devguard/checkers/container.py +137 -0
- devguard/checkers/domain.py +189 -0
- devguard/checkers/firecrawl.py +117 -0
- devguard/checkers/fly.py +225 -0
- devguard/checkers/github.py +210 -0
- devguard/checkers/npm.py +327 -0
- devguard/checkers/npm_security.py +244 -0
- devguard/checkers/redteam.py +290 -0
- devguard/checkers/secret.py +279 -0
- devguard/checkers/swarm.py +376 -0
- devguard/checkers/tailscale.py +143 -0
- devguard/checkers/tailsnitch.py +303 -0
- devguard/checkers/tavily.py +179 -0
- devguard/checkers/vercel.py +192 -0
- devguard/cli.py +1510 -0
- devguard/cli_helpers.py +189 -0
- devguard/config.py +249 -0
- devguard/core.py +293 -0
- devguard/dashboard.py +715 -0
- devguard/discovery.py +363 -0
- devguard/http_client.py +142 -0
- devguard/llm_service.py +481 -0
- devguard/mcp_server.py +259 -0
- devguard/metrics.py +144 -0
- devguard/models.py +208 -0
- devguard/reporting.py +1571 -0
- devguard/sarif.py +295 -0
- devguard/scripts/ANALYSIS_SUMMARY.md +141 -0
- devguard/scripts/README.md +221 -0
- devguard/scripts/auto_fix_recommendations.py +145 -0
- devguard/scripts/generate_npmignore.py +175 -0
- devguard/scripts/generate_security_report.py +324 -0
- devguard/scripts/prepublish_check.sh +29 -0
- devguard/scripts/redteam_npm_packages.py +1262 -0
- devguard/scripts/review_all_repos.py +300 -0
- devguard/spec.py +617 -0
- devguard/sweeps/__init__.py +23 -0
- devguard/sweeps/ai_editor_config_audit.py +697 -0
- devguard/sweeps/cargo_publish_audit.py +655 -0
- devguard/sweeps/dependency_audit.py +419 -0
- devguard/sweeps/gitignore_audit.py +336 -0
- devguard/sweeps/local_dev.py +260 -0
- devguard/sweeps/local_dirty_worktree_secrets.py +521 -0
- devguard/sweeps/project_flaudit.py +636 -0
- devguard/sweeps/public_github_secrets.py +680 -0
- devguard/sweeps/publish_audit.py +478 -0
- devguard/sweeps/ssh_key_audit.py +327 -0
- devguard/utils.py +174 -0
- devguard-0.2.0.dist-info/METADATA +225 -0
- devguard-0.2.0.dist-info/RECORD +60 -0
- devguard-0.2.0.dist-info/WHEEL +4 -0
- devguard-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
"""Cargo publish audit sweep: check Rust repos for correct CI publish pipelines.
|
|
2
|
+
|
|
3
|
+
Scans git repos under a dev root for Cargo.toml, then audits the full e2e
|
|
4
|
+
publish pipeline: tag triggers, OIDC trusted publishing, dry-run checks,
|
|
5
|
+
version consistency, and workflow correctness.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _utc_now() -> str:
|
|
22
|
+
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _default_dev_root() -> Path:
|
|
26
|
+
return Path(os.getenv("DEV_DIR") or "~/Documents/dev").expanduser()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _iter_rust_repos(root: Path, max_depth: int, exclude_globs: list[str]) -> list[Path]:
|
|
30
|
+
"""Discover git repos with Cargo.toml under root."""
|
|
31
|
+
root = root.resolve()
|
|
32
|
+
max_depth = max(0, min(int(max_depth), 6))
|
|
33
|
+
junk = {
|
|
34
|
+
"node_modules",
|
|
35
|
+
".venv",
|
|
36
|
+
"venv",
|
|
37
|
+
"dist",
|
|
38
|
+
"build",
|
|
39
|
+
".git",
|
|
40
|
+
".cache",
|
|
41
|
+
".state",
|
|
42
|
+
"__pycache__",
|
|
43
|
+
"_trash",
|
|
44
|
+
"_scratch",
|
|
45
|
+
"_external",
|
|
46
|
+
"_archive",
|
|
47
|
+
"_forks",
|
|
48
|
+
"target",
|
|
49
|
+
}
|
|
50
|
+
repos: list[Path] = []
|
|
51
|
+
stack: list[tuple[Path, int]] = [(root, 0)]
|
|
52
|
+
seen: set[Path] = set()
|
|
53
|
+
while stack:
|
|
54
|
+
cur, depth = stack.pop()
|
|
55
|
+
if cur in seen:
|
|
56
|
+
continue
|
|
57
|
+
seen.add(cur)
|
|
58
|
+
if (cur / ".git").exists() and (cur / "Cargo.toml").exists():
|
|
59
|
+
if not any(fnmatch.fnmatch(str(cur), g) for g in exclude_globs):
|
|
60
|
+
repos.append(cur)
|
|
61
|
+
continue
|
|
62
|
+
if depth >= max_depth:
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
for child in cur.iterdir():
|
|
66
|
+
if not child.is_dir():
|
|
67
|
+
continue
|
|
68
|
+
name = child.name
|
|
69
|
+
if name in junk or name.startswith("."):
|
|
70
|
+
continue
|
|
71
|
+
stack.append((child, depth + 1))
|
|
72
|
+
except Exception:
|
|
73
|
+
continue
|
|
74
|
+
return sorted(repos)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_likely_public(repo: Path) -> bool:
|
|
78
|
+
for name in ("LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE"):
|
|
79
|
+
if (repo / name).exists():
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _read_cargo_version(repo: Path) -> str | None:
|
|
85
|
+
"""Read version from Cargo.toml (root package)."""
|
|
86
|
+
cargo_toml = repo / "Cargo.toml"
|
|
87
|
+
if not cargo_toml.is_file():
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
text = cargo_toml.read_text(encoding="utf-8", errors="replace")
|
|
91
|
+
# Simple regex -- good enough for [package] version = "x.y.z"
|
|
92
|
+
m = re.search(r'^\[package\].*?^version\s*=\s*"([^"]+)"', text, re.MULTILINE | re.DOTALL)
|
|
93
|
+
return m.group(1) if m else None
|
|
94
|
+
except Exception:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_cargo_publish(repo: Path) -> bool | None:
|
|
99
|
+
"""Check if publish = false in Cargo.toml."""
|
|
100
|
+
cargo_toml = repo / "Cargo.toml"
|
|
101
|
+
if not cargo_toml.is_file():
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
text = cargo_toml.read_text(encoding="utf-8", errors="replace")
|
|
105
|
+
# Check for publish = false in [package] section
|
|
106
|
+
pkg_match = re.search(r"^\[package\](.*?)(?=^\[|\Z)", text, re.MULTILINE | re.DOTALL)
|
|
107
|
+
if pkg_match:
|
|
108
|
+
pkg_section = pkg_match.group(1)
|
|
109
|
+
if re.search(r"^publish\s*=\s*false", pkg_section, re.MULTILINE):
|
|
110
|
+
return False
|
|
111
|
+
return True
|
|
112
|
+
except Exception:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _get_latest_version_tag(repo: Path) -> str | None:
|
|
117
|
+
"""Get latest semver tag from git."""
|
|
118
|
+
try:
|
|
119
|
+
res = subprocess.run(
|
|
120
|
+
["git", "tag", "--sort=-v:refname"],
|
|
121
|
+
cwd=str(repo),
|
|
122
|
+
capture_output=True,
|
|
123
|
+
text=True,
|
|
124
|
+
timeout=10,
|
|
125
|
+
)
|
|
126
|
+
if res.returncode != 0:
|
|
127
|
+
return None
|
|
128
|
+
for line in res.stdout.strip().splitlines():
|
|
129
|
+
tag = line.strip()
|
|
130
|
+
# Match v0.1.0, 0.1.0, crate-name-v0.1.0
|
|
131
|
+
m = re.search(r"v?(\d+\.\d+\.\d+(?:-[\w.]+)?)", tag)
|
|
132
|
+
if m:
|
|
133
|
+
return m.group(1)
|
|
134
|
+
return None
|
|
135
|
+
except Exception:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _read_workflow_files(repo: Path) -> list[tuple[str, str]]:
|
|
140
|
+
"""Read all .yml/.yaml files from .github/workflows/."""
|
|
141
|
+
wf_dir = repo / ".github" / "workflows"
|
|
142
|
+
if not wf_dir.is_dir():
|
|
143
|
+
return []
|
|
144
|
+
results = []
|
|
145
|
+
for f in sorted(wf_dir.iterdir()):
|
|
146
|
+
if f.suffix in (".yml", ".yaml") and f.is_file():
|
|
147
|
+
try:
|
|
148
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
149
|
+
results.append((f.name, text))
|
|
150
|
+
except Exception:
|
|
151
|
+
continue
|
|
152
|
+
return results
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class Finding:
|
|
157
|
+
check: str
|
|
158
|
+
severity: str # "error", "warning", "info"
|
|
159
|
+
message: str
|
|
160
|
+
detail: str = ""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class RepoAuditResult:
|
|
165
|
+
repo_path: str
|
|
166
|
+
repo_name: str
|
|
167
|
+
is_public: bool
|
|
168
|
+
cargo_version: str | None
|
|
169
|
+
latest_tag: str | None
|
|
170
|
+
publish_enabled: bool
|
|
171
|
+
has_workflows: bool
|
|
172
|
+
findings: list[Finding] = field(default_factory=list)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _read_features(repo: Path) -> set[str]:
|
|
176
|
+
"""Extract feature names from Cargo.toml [features] section."""
|
|
177
|
+
cargo_toml = repo / "Cargo.toml"
|
|
178
|
+
if not cargo_toml.is_file():
|
|
179
|
+
return set()
|
|
180
|
+
try:
|
|
181
|
+
text = cargo_toml.read_text(encoding="utf-8", errors="replace")
|
|
182
|
+
feat_section = re.search(r"^\[features\](.*?)(?=^\[|\Z)", text, re.MULTILINE | re.DOTALL)
|
|
183
|
+
if not feat_section:
|
|
184
|
+
return set()
|
|
185
|
+
features = set()
|
|
186
|
+
for m in re.finditer(r"^(\w[\w-]*)\s*=", feat_section.group(1), re.MULTILINE):
|
|
187
|
+
features.add(m.group(1))
|
|
188
|
+
return features
|
|
189
|
+
except Exception:
|
|
190
|
+
return set()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _check_feature_gated_tests(repo: Path, result: RepoAuditResult) -> None:
|
|
194
|
+
"""Flag integration tests that import feature-gated modules without a #![cfg] gate.
|
|
195
|
+
|
|
196
|
+
Pattern: a test file uses `use crate_name::feature_module::` but doesn't have
|
|
197
|
+
`#![cfg(feature = "...")]` at the top. This causes compilation failures when
|
|
198
|
+
running `cargo test` without that feature enabled.
|
|
199
|
+
"""
|
|
200
|
+
features = _read_features(repo)
|
|
201
|
+
if not features:
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
# Find crate name from Cargo.toml
|
|
205
|
+
cargo_toml = repo / "Cargo.toml"
|
|
206
|
+
try:
|
|
207
|
+
text = cargo_toml.read_text(encoding="utf-8", errors="replace")
|
|
208
|
+
name_match = re.search(
|
|
209
|
+
r'^\[package\].*?^name\s*=\s*"([^"]+)"', text, re.MULTILINE | re.DOTALL
|
|
210
|
+
)
|
|
211
|
+
if not name_match:
|
|
212
|
+
return
|
|
213
|
+
crate_name = name_match.group(1).replace("-", "_")
|
|
214
|
+
except Exception:
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
# Scan tests/ and also workspace member tests/
|
|
218
|
+
test_dirs = [repo / "tests"]
|
|
219
|
+
# Check workspace members
|
|
220
|
+
members_match = re.search(
|
|
221
|
+
r"^\[workspace\].*?members\s*=\s*\[(.*?)\]", text, re.MULTILINE | re.DOTALL
|
|
222
|
+
)
|
|
223
|
+
if members_match:
|
|
224
|
+
for m in re.finditer(r'"([^"]+)"', members_match.group(1)):
|
|
225
|
+
member_path = repo / m.group(1)
|
|
226
|
+
test_dirs.append(member_path / "tests")
|
|
227
|
+
# Also read that member's features and crate name
|
|
228
|
+
member_toml = member_path / "Cargo.toml"
|
|
229
|
+
if member_toml.is_file():
|
|
230
|
+
try:
|
|
231
|
+
mt = member_toml.read_text(encoding="utf-8", errors="replace")
|
|
232
|
+
mname = re.search(
|
|
233
|
+
r'^\[package\].*?^name\s*=\s*"([^"]+)"', mt, re.MULTILINE | re.DOTALL
|
|
234
|
+
)
|
|
235
|
+
if mname:
|
|
236
|
+
member_crate = mname.group(1).replace("-", "_")
|
|
237
|
+
mfeats = re.search(
|
|
238
|
+
r"^\[features\](.*?)(?=^\[|\Z)", mt, re.MULTILINE | re.DOTALL
|
|
239
|
+
)
|
|
240
|
+
if mfeats:
|
|
241
|
+
for feat_m in re.finditer(
|
|
242
|
+
r"^(\w[\w-]*)\s*=", mfeats.group(1), re.MULTILINE
|
|
243
|
+
):
|
|
244
|
+
features.add(feat_m.group(1))
|
|
245
|
+
crate_name = member_crate # use member name for import matching
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
for test_dir in test_dirs:
|
|
250
|
+
if not test_dir.is_dir():
|
|
251
|
+
continue
|
|
252
|
+
try:
|
|
253
|
+
for test_file in test_dir.iterdir():
|
|
254
|
+
if not test_file.is_file() or test_file.suffix != ".rs":
|
|
255
|
+
continue
|
|
256
|
+
try:
|
|
257
|
+
content = test_file.read_text(encoding="utf-8", errors="replace")
|
|
258
|
+
except Exception:
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
# Check if file has a top-level cfg gate
|
|
262
|
+
has_cfg = bool(re.search(r"#!\[cfg\(feature\s*=", content[:500]))
|
|
263
|
+
if has_cfg:
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
# Check if file imports feature-gated modules
|
|
267
|
+
for feat in features:
|
|
268
|
+
feat_mod = feat.replace("-", "_")
|
|
269
|
+
# Look for `use crate_name::feat_mod` or `crate_name::feat_mod::`
|
|
270
|
+
pattern = rf"\b{re.escape(crate_name)}::{re.escape(feat_mod)}\b"
|
|
271
|
+
if re.search(pattern, content):
|
|
272
|
+
result.findings.append(
|
|
273
|
+
Finding(
|
|
274
|
+
check="ungated_feature_test",
|
|
275
|
+
severity="error",
|
|
276
|
+
message=f"{test_file.name}: imports `{crate_name}::{feat_mod}` "
|
|
277
|
+
f'but lacks `#![cfg(feature = "{feat}")]`',
|
|
278
|
+
detail="This test will fail to compile without the feature enabled. "
|
|
279
|
+
'Add `#![cfg(feature = "...")]` at the top of the test file.',
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
break # one finding per file is enough
|
|
283
|
+
except Exception:
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _audit_repo(repo: Path) -> RepoAuditResult:
|
|
288
|
+
"""Run all cargo publish checks on a single repo."""
|
|
289
|
+
name = repo.name
|
|
290
|
+
is_public = _is_likely_public(repo)
|
|
291
|
+
cargo_version = _read_cargo_version(repo)
|
|
292
|
+
publish_enabled = _read_cargo_publish(repo) is not False
|
|
293
|
+
latest_tag = _get_latest_version_tag(repo)
|
|
294
|
+
workflows = _read_workflow_files(repo)
|
|
295
|
+
has_workflows = len(workflows) > 0
|
|
296
|
+
|
|
297
|
+
result = RepoAuditResult(
|
|
298
|
+
repo_path=str(repo),
|
|
299
|
+
repo_name=name,
|
|
300
|
+
is_public=is_public,
|
|
301
|
+
cargo_version=cargo_version,
|
|
302
|
+
latest_tag=latest_tag,
|
|
303
|
+
publish_enabled=publish_enabled,
|
|
304
|
+
has_workflows=has_workflows,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Skip unpublishable crates
|
|
308
|
+
if not publish_enabled:
|
|
309
|
+
result.findings.append(
|
|
310
|
+
Finding(
|
|
311
|
+
check="publish_disabled",
|
|
312
|
+
severity="info",
|
|
313
|
+
message="publish = false in Cargo.toml; skipping publish checks",
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
return result
|
|
317
|
+
|
|
318
|
+
# Check: has any workflow files at all
|
|
319
|
+
if not has_workflows:
|
|
320
|
+
sev = "error" if is_public else "warning"
|
|
321
|
+
result.findings.append(
|
|
322
|
+
Finding(
|
|
323
|
+
check="no_workflows",
|
|
324
|
+
severity=sev,
|
|
325
|
+
message="No .github/workflows/ directory or no workflow files",
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
return result
|
|
329
|
+
|
|
330
|
+
all_text = "\n".join(text for _, text in workflows)
|
|
331
|
+
|
|
332
|
+
# Check: has a publish workflow (any file with cargo publish)
|
|
333
|
+
publish_files = [
|
|
334
|
+
(f, t) for f, t in workflows if "cargo publish" in t.lower() or "cargo-publish" in t.lower()
|
|
335
|
+
]
|
|
336
|
+
has_publish_wf = len(publish_files) > 0
|
|
337
|
+
|
|
338
|
+
if not has_publish_wf:
|
|
339
|
+
sev = "error" if is_public else "warning"
|
|
340
|
+
result.findings.append(
|
|
341
|
+
Finding(
|
|
342
|
+
check="no_publish_workflow",
|
|
343
|
+
severity=sev,
|
|
344
|
+
message="No workflow contains `cargo publish`",
|
|
345
|
+
detail="Expected at least one workflow with a publish step",
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Check: tag-triggered release
|
|
350
|
+
has_tag_trigger = bool(re.search(r"on:\s*\n\s+push:\s*\n\s+tags:", all_text, re.MULTILINE))
|
|
351
|
+
# Also check workflow_dispatch / release event as alternatives
|
|
352
|
+
has_release_trigger = "release:" in all_text or "workflow_dispatch:" in all_text
|
|
353
|
+
has_any_release_trigger = has_tag_trigger or has_release_trigger
|
|
354
|
+
|
|
355
|
+
if has_publish_wf and not has_any_release_trigger:
|
|
356
|
+
result.findings.append(
|
|
357
|
+
Finding(
|
|
358
|
+
check="no_tag_trigger",
|
|
359
|
+
severity="warning",
|
|
360
|
+
message="Publish workflow has no tag/release trigger",
|
|
361
|
+
detail="Expected `on: push: tags:` or `on: release:` or `workflow_dispatch`",
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Check: OIDC trusted publishing (id-token: write)
|
|
366
|
+
has_oidc = bool(re.search(r"id-token\s*:\s*write", all_text))
|
|
367
|
+
# Also check for crates.io trusted publishing action patterns
|
|
368
|
+
has_trusted_publish_action = "trusted-publishing" in all_text.lower()
|
|
369
|
+
|
|
370
|
+
if has_publish_wf and not has_oidc and not has_trusted_publish_action:
|
|
371
|
+
sev = "error" if is_public else "warning"
|
|
372
|
+
result.findings.append(
|
|
373
|
+
Finding(
|
|
374
|
+
check="no_oidc",
|
|
375
|
+
severity=sev,
|
|
376
|
+
message="No OIDC trusted publishing setup (missing `id-token: write`)",
|
|
377
|
+
detail="Trusted publishing avoids long-lived API tokens. "
|
|
378
|
+
"Set `permissions: id-token: write` and configure crates.io trusted publishers.",
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# Check: dry-run on PRs
|
|
383
|
+
has_dry_run = bool(re.search(r"cargo\s+publish\s+.*--dry-run", all_text))
|
|
384
|
+
# Also check for separate CI workflow with cargo check/test
|
|
385
|
+
has_ci_workflow = any(
|
|
386
|
+
("pull_request" in t or "push:" in t)
|
|
387
|
+
and ("cargo test" in t or "cargo check" in t or "cargo clippy" in t)
|
|
388
|
+
for _, t in workflows
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
if has_publish_wf and not has_dry_run:
|
|
392
|
+
result.findings.append(
|
|
393
|
+
Finding(
|
|
394
|
+
check="no_dry_run",
|
|
395
|
+
severity="warning",
|
|
396
|
+
message="No `cargo publish --dry-run` found in CI",
|
|
397
|
+
detail="Dry-run catches packaging errors (missing files, bad metadata) before tagging. "
|
|
398
|
+
"Consider adding it to PR checks.",
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if not has_ci_workflow and not has_dry_run:
|
|
403
|
+
result.findings.append(
|
|
404
|
+
Finding(
|
|
405
|
+
check="no_pr_ci",
|
|
406
|
+
severity="warning",
|
|
407
|
+
message="No PR/push CI workflow with cargo test/check/clippy",
|
|
408
|
+
detail="Basic CI catches build failures before publish",
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# Check: hardcoded tokens (anti-pattern)
|
|
413
|
+
for fname, text in workflows:
|
|
414
|
+
# Look for literal token values (not ${{ secrets.X }})
|
|
415
|
+
if re.search(r'CARGO_REGISTRY_TOKEN\s*[:=]\s*["\']?[a-zA-Z0-9]{20,}', text):
|
|
416
|
+
result.findings.append(
|
|
417
|
+
Finding(
|
|
418
|
+
check="hardcoded_token",
|
|
419
|
+
severity="error",
|
|
420
|
+
message=f"Possible hardcoded registry token in {fname}",
|
|
421
|
+
detail="Use ${{{{ secrets.CARGO_REGISTRY_TOKEN }}}} or OIDC trusted publishing",
|
|
422
|
+
)
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
# Check: secret-based auth when OIDC is available (migration target)
|
|
426
|
+
for fname, text in publish_files:
|
|
427
|
+
uses_secret = bool(re.search(r"secrets\.CARGO_REGISTRY_TOKEN", text))
|
|
428
|
+
if uses_secret and not has_oidc:
|
|
429
|
+
result.findings.append(
|
|
430
|
+
Finding(
|
|
431
|
+
check="secret_based_auth",
|
|
432
|
+
severity="warning",
|
|
433
|
+
message=f"{fname}: uses secrets.CARGO_REGISTRY_TOKEN instead of OIDC",
|
|
434
|
+
detail="Migrate to rust-lang/crates-io-auth-action@v1 for short-lived tokens. "
|
|
435
|
+
"Add `permissions: id-token: write` and remove the secret.",
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
# Check: LICENSE file for public repos
|
|
440
|
+
if is_public:
|
|
441
|
+
has_license = any(
|
|
442
|
+
(repo / name).exists()
|
|
443
|
+
for name in (
|
|
444
|
+
"LICENSE",
|
|
445
|
+
"LICENSE.md",
|
|
446
|
+
"LICENSE.txt",
|
|
447
|
+
"LICENSE-MIT",
|
|
448
|
+
"LICENSE-APACHE",
|
|
449
|
+
"LICENCE",
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
if not has_license:
|
|
453
|
+
result.findings.append(
|
|
454
|
+
Finding(
|
|
455
|
+
check="no_license_file",
|
|
456
|
+
severity="error",
|
|
457
|
+
message="Public repo has no LICENSE file",
|
|
458
|
+
detail="Cargo.toml may declare a license but crates.io and legal compliance "
|
|
459
|
+
"require the actual license text. Add LICENSE-MIT and/or LICENSE-APACHE.",
|
|
460
|
+
)
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# Check: CI quality (fmt + clippy in CI workflows)
|
|
464
|
+
if has_ci_workflow:
|
|
465
|
+
ci_text = "\n".join(t for _, t in workflows if "pull_request" in t or "push:" in t)
|
|
466
|
+
has_fmt = bool(re.search(r"cargo\s+fmt", ci_text))
|
|
467
|
+
has_clippy = bool(re.search(r"cargo\s+clippy", ci_text))
|
|
468
|
+
if not has_fmt:
|
|
469
|
+
result.findings.append(
|
|
470
|
+
Finding(
|
|
471
|
+
check="ci_no_fmt",
|
|
472
|
+
severity="info",
|
|
473
|
+
message="CI does not run `cargo fmt --check`",
|
|
474
|
+
detail="Formatting drift accumulates without CI enforcement.",
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
if not has_clippy:
|
|
478
|
+
result.findings.append(
|
|
479
|
+
Finding(
|
|
480
|
+
check="ci_no_clippy",
|
|
481
|
+
severity="info",
|
|
482
|
+
message="CI does not run `cargo clippy`",
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
# Check: feature-gated tests without cfg gate.
|
|
487
|
+
# Tests that import feature-gated modules (e.g., `use crate::qdrant::`) but lack
|
|
488
|
+
# `#![cfg(feature = "...")]` will fail to compile without that feature enabled.
|
|
489
|
+
_check_feature_gated_tests(repo, result)
|
|
490
|
+
|
|
491
|
+
# Check: version vs tag consistency
|
|
492
|
+
if cargo_version and latest_tag:
|
|
493
|
+
if cargo_version != latest_tag:
|
|
494
|
+
# Could be intentional (dev version bump), so just info
|
|
495
|
+
result.findings.append(
|
|
496
|
+
Finding(
|
|
497
|
+
check="version_tag_mismatch",
|
|
498
|
+
severity="info",
|
|
499
|
+
message=f"Cargo.toml version ({cargo_version}) != latest tag ({latest_tag})",
|
|
500
|
+
detail="If you've bumped the version for a pending release, this is expected. "
|
|
501
|
+
"Otherwise, ensure tags match published versions.",
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
elif cargo_version and not latest_tag:
|
|
505
|
+
result.findings.append(
|
|
506
|
+
Finding(
|
|
507
|
+
check="no_version_tags",
|
|
508
|
+
severity="info",
|
|
509
|
+
message=f"Cargo.toml version is {cargo_version} but no semver tags found",
|
|
510
|
+
detail="Tag releases with `v{version}` for traceability",
|
|
511
|
+
)
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
# Check: publish workflow has checkout + build/test before publish
|
|
515
|
+
for fname, text in publish_files:
|
|
516
|
+
has_checkout = "actions/checkout" in text
|
|
517
|
+
has_test_before = bool(
|
|
518
|
+
re.search(r"cargo\s+(test|build|check|clippy).*\n.*cargo\s+publish", text, re.DOTALL)
|
|
519
|
+
)
|
|
520
|
+
if not has_checkout:
|
|
521
|
+
result.findings.append(
|
|
522
|
+
Finding(
|
|
523
|
+
check="missing_checkout",
|
|
524
|
+
severity="warning",
|
|
525
|
+
message=f"{fname}: publish workflow missing actions/checkout",
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
if not has_test_before and "cargo test" not in text and "cargo build" not in text:
|
|
529
|
+
result.findings.append(
|
|
530
|
+
Finding(
|
|
531
|
+
check="no_test_before_publish",
|
|
532
|
+
severity="warning",
|
|
533
|
+
message=f"{fname}: no test/build step before cargo publish",
|
|
534
|
+
detail="Run tests before publishing to catch regressions",
|
|
535
|
+
)
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Check: environment protection (for publish jobs)
|
|
539
|
+
for fname, text in publish_files:
|
|
540
|
+
has_environment = bool(re.search(r"environment\s*:", text))
|
|
541
|
+
if not has_environment and has_oidc:
|
|
542
|
+
result.findings.append(
|
|
543
|
+
Finding(
|
|
544
|
+
check="no_environment_protection",
|
|
545
|
+
severity="info",
|
|
546
|
+
message=f"{fname}: OIDC publish without GitHub environment protection",
|
|
547
|
+
detail="Using a named environment (e.g., `crates-io`) adds an approval gate",
|
|
548
|
+
)
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
return result
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def audit_cargo_publish(
|
|
555
|
+
*,
|
|
556
|
+
dev_root: Path | None = None,
|
|
557
|
+
max_depth: int = 2,
|
|
558
|
+
exclude_repo_globs: list[str] | None = None,
|
|
559
|
+
only_public: bool = False,
|
|
560
|
+
repo_names: list[str] | None = None,
|
|
561
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
562
|
+
"""Audit cargo publish pipelines across repos and return a report."""
|
|
563
|
+
errors: list[str] = []
|
|
564
|
+
root = dev_root if dev_root is not None else _default_dev_root()
|
|
565
|
+
globs = [g for g in (exclude_repo_globs or []) if isinstance(g, str) and g.strip()]
|
|
566
|
+
|
|
567
|
+
repos = _iter_rust_repos(root, max_depth=max_depth, exclude_globs=globs)
|
|
568
|
+
|
|
569
|
+
if repo_names:
|
|
570
|
+
name_set = set(repo_names)
|
|
571
|
+
repos = [r for r in repos if r.name in name_set]
|
|
572
|
+
|
|
573
|
+
if only_public:
|
|
574
|
+
repos = [r for r in repos if _is_likely_public(r)]
|
|
575
|
+
|
|
576
|
+
results: list[RepoAuditResult] = []
|
|
577
|
+
for repo in repos:
|
|
578
|
+
try:
|
|
579
|
+
result = _audit_repo(repo)
|
|
580
|
+
results.append(result)
|
|
581
|
+
except Exception as exc:
|
|
582
|
+
errors.append(f"failed to audit {repo}: {exc}")
|
|
583
|
+
|
|
584
|
+
# Sort: public repos with errors first
|
|
585
|
+
results.sort(
|
|
586
|
+
key=lambda r: (
|
|
587
|
+
-r.is_public,
|
|
588
|
+
-sum(1 for f in r.findings if f.severity == "error"),
|
|
589
|
+
-sum(1 for f in r.findings if f.severity == "warning"),
|
|
590
|
+
r.repo_name,
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
# Summary stats
|
|
595
|
+
repos_with_errors = [r for r in results if any(f.severity == "error" for f in r.findings)]
|
|
596
|
+
repos_with_warnings = [r for r in results if any(f.severity == "warning" for f in r.findings)]
|
|
597
|
+
check_counts: dict[str, int] = {}
|
|
598
|
+
for r in results:
|
|
599
|
+
for f in r.findings:
|
|
600
|
+
check_counts[f.check] = check_counts.get(f.check, 0) + 1
|
|
601
|
+
|
|
602
|
+
report: dict[str, Any] = {
|
|
603
|
+
"generated_at": _utc_now(),
|
|
604
|
+
"scope": {
|
|
605
|
+
"dev_root": str(root),
|
|
606
|
+
"repos_scanned": len(repos),
|
|
607
|
+
"rust_repos_found": len(results),
|
|
608
|
+
"max_depth": max_depth,
|
|
609
|
+
"only_public": only_public,
|
|
610
|
+
"repo_names_filter": repo_names,
|
|
611
|
+
"exclude_repo_globs": globs,
|
|
612
|
+
},
|
|
613
|
+
"summary": {
|
|
614
|
+
"repos_with_errors": len(repos_with_errors),
|
|
615
|
+
"repos_with_errors_list": [r.repo_name for r in repos_with_errors],
|
|
616
|
+
"repos_with_warnings": len(repos_with_warnings),
|
|
617
|
+
"repos_with_warnings_list": [r.repo_name for r in repos_with_warnings],
|
|
618
|
+
"total_findings": sum(len(r.findings) for r in results),
|
|
619
|
+
"findings_by_check": sorted(check_counts.items(), key=lambda x: -x[1]),
|
|
620
|
+
"total_errors": sum(1 for r in results for f in r.findings if f.severity == "error"),
|
|
621
|
+
"total_warnings": sum(
|
|
622
|
+
1 for r in results for f in r.findings if f.severity == "warning"
|
|
623
|
+
),
|
|
624
|
+
},
|
|
625
|
+
"repos": [
|
|
626
|
+
{
|
|
627
|
+
"repo_path": r.repo_path,
|
|
628
|
+
"repo_name": r.repo_name,
|
|
629
|
+
"is_public": r.is_public,
|
|
630
|
+
"cargo_version": r.cargo_version,
|
|
631
|
+
"latest_tag": r.latest_tag,
|
|
632
|
+
"publish_enabled": r.publish_enabled,
|
|
633
|
+
"has_workflows": r.has_workflows,
|
|
634
|
+
"findings": [
|
|
635
|
+
{
|
|
636
|
+
"check": f.check,
|
|
637
|
+
"severity": f.severity,
|
|
638
|
+
"message": f.message,
|
|
639
|
+
**({"detail": f.detail} if f.detail else {}),
|
|
640
|
+
}
|
|
641
|
+
for f in r.findings
|
|
642
|
+
],
|
|
643
|
+
}
|
|
644
|
+
for r in results
|
|
645
|
+
if r.findings # only include repos with findings
|
|
646
|
+
][:200],
|
|
647
|
+
"clean_repos": [r.repo_name for r in results if not r.findings],
|
|
648
|
+
"errors": errors,
|
|
649
|
+
}
|
|
650
|
+
return report, errors
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def write_report(path: Path, report: dict[str, Any]) -> None:
|
|
654
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
655
|
+
path.write_text(json.dumps(report, indent=2) + "\n")
|