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.
Files changed (60) hide show
  1. devguard/INTEGRATION_SUMMARY.md +121 -0
  2. devguard/__init__.py +3 -0
  3. devguard/__main__.py +6 -0
  4. devguard/checkers/__init__.py +41 -0
  5. devguard/checkers/api_usage.py +523 -0
  6. devguard/checkers/aws_cost.py +331 -0
  7. devguard/checkers/aws_iam.py +284 -0
  8. devguard/checkers/base.py +25 -0
  9. devguard/checkers/container.py +137 -0
  10. devguard/checkers/domain.py +189 -0
  11. devguard/checkers/firecrawl.py +117 -0
  12. devguard/checkers/fly.py +225 -0
  13. devguard/checkers/github.py +210 -0
  14. devguard/checkers/npm.py +327 -0
  15. devguard/checkers/npm_security.py +244 -0
  16. devguard/checkers/redteam.py +290 -0
  17. devguard/checkers/secret.py +279 -0
  18. devguard/checkers/swarm.py +376 -0
  19. devguard/checkers/tailscale.py +143 -0
  20. devguard/checkers/tailsnitch.py +303 -0
  21. devguard/checkers/tavily.py +179 -0
  22. devguard/checkers/vercel.py +192 -0
  23. devguard/cli.py +1510 -0
  24. devguard/cli_helpers.py +189 -0
  25. devguard/config.py +249 -0
  26. devguard/core.py +293 -0
  27. devguard/dashboard.py +715 -0
  28. devguard/discovery.py +363 -0
  29. devguard/http_client.py +142 -0
  30. devguard/llm_service.py +481 -0
  31. devguard/mcp_server.py +259 -0
  32. devguard/metrics.py +144 -0
  33. devguard/models.py +208 -0
  34. devguard/reporting.py +1571 -0
  35. devguard/sarif.py +295 -0
  36. devguard/scripts/ANALYSIS_SUMMARY.md +141 -0
  37. devguard/scripts/README.md +221 -0
  38. devguard/scripts/auto_fix_recommendations.py +145 -0
  39. devguard/scripts/generate_npmignore.py +175 -0
  40. devguard/scripts/generate_security_report.py +324 -0
  41. devguard/scripts/prepublish_check.sh +29 -0
  42. devguard/scripts/redteam_npm_packages.py +1262 -0
  43. devguard/scripts/review_all_repos.py +300 -0
  44. devguard/spec.py +617 -0
  45. devguard/sweeps/__init__.py +23 -0
  46. devguard/sweeps/ai_editor_config_audit.py +697 -0
  47. devguard/sweeps/cargo_publish_audit.py +655 -0
  48. devguard/sweeps/dependency_audit.py +419 -0
  49. devguard/sweeps/gitignore_audit.py +336 -0
  50. devguard/sweeps/local_dev.py +260 -0
  51. devguard/sweeps/local_dirty_worktree_secrets.py +521 -0
  52. devguard/sweeps/project_flaudit.py +636 -0
  53. devguard/sweeps/public_github_secrets.py +680 -0
  54. devguard/sweeps/publish_audit.py +478 -0
  55. devguard/sweeps/ssh_key_audit.py +327 -0
  56. devguard/utils.py +174 -0
  57. devguard-0.2.0.dist-info/METADATA +225 -0
  58. devguard-0.2.0.dist-info/RECORD +60 -0
  59. devguard-0.2.0.dist-info/WHEEL +4 -0
  60. 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")