invigil 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.
invigil/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Invigil — grade a repo against a product-quality doctrine, not code style.
2
+
3
+ The doctrine is the QUALITY-PLAYBOOK: a stranger boots it in 10 minutes (G1),
4
+ every error tells the fix (G2), published artifacts are machine-verified daily
5
+ (G3), supply-chain evidence is public (G4), five doors open (G5). Invigil turns
6
+ those Gates into mechanical, exact-fix-reporting checks.
7
+ """
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,132 @@
1
+ """The check registry.
2
+
3
+ Each check is a function `(Context) -> CheckResult` decorated with `@register`,
4
+ which pins its Check metadata (id, gate, weight, discipline). `run_all` executes
5
+ every registered check against a repo, honouring `checks.disable` from config.
6
+
7
+ Modules are imported for their side effect of registering checks. Add a new
8
+ discipline module here and its checks light up automatically.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable
14
+
15
+ from ..context import Context
16
+ from ..model import Check, CheckResult, Status
17
+
18
+ REGISTRY: list[tuple[Check, Callable[[Context], CheckResult]]] = []
19
+
20
+
21
+ def register(**meta):
22
+ """Decorator: attach Check metadata to a check function and register it."""
23
+
24
+ def wrap(fn: Callable[[Context], CheckResult]) -> Callable[[Context], CheckResult]:
25
+ check = Check(**meta)
26
+ # Expose the Check on the function so its body can build CheckResults
27
+ # without repeating the metadata.
28
+ fn.__invigil__ = check # type: ignore[attr-defined]
29
+ REGISTRY.append((check, fn))
30
+ return fn
31
+
32
+ return wrap
33
+
34
+
35
+ def run_all(
36
+ ctx: Context,
37
+ *,
38
+ only_layers: set[str] | None = None,
39
+ only_groups: set[str] | None = None,
40
+ offline: bool = False,
41
+ ) -> list[CheckResult]:
42
+ """Run the registered checks, optionally filtered by layer/group.
43
+
44
+ - `only_layers` / `only_groups`: run just that subset (the rest are omitted,
45
+ not reported — used by `invigil check <group>` and `--layer`).
46
+ - `offline`: never touch the network; `network`-layer checks become a SKIP
47
+ instead of running (used by pre-commit and `--offline`).
48
+ """
49
+ results: list[CheckResult] = []
50
+ for check, fn in REGISTRY:
51
+ if only_layers and check.layer not in only_layers:
52
+ continue
53
+ if only_groups and check.group not in only_groups:
54
+ continue
55
+ if check.id in ctx.config.disabled_checks:
56
+ results.append(CheckResult(check, Status.SKIP, detail="disabled in .invigil.yml"))
57
+ continue
58
+ if offline and check.layer == "network":
59
+ results.append(CheckResult(check, Status.SKIP, detail="offline — network check skipped"))
60
+ continue
61
+ try:
62
+ results.append(fn(ctx))
63
+ except Exception as exc: # a check must never crash the gate
64
+ results.append(CheckResult(check, Status.WARN, detail=f"check errored: {exc}", fix="file an Invigil bug"))
65
+ return results
66
+
67
+
68
+ # Import check modules for their registration side effects. Order is cosmetic;
69
+ # checks are grouped in the report by gate, not by import order.
70
+ from . import ( # noqa: E402,F401
71
+ ai_native,
72
+ g1_stranger,
73
+ g2_errors,
74
+ g3_supply,
75
+ g4_evidence,
76
+ g5_doors,
77
+ tier1_secrets,
78
+ )
79
+
80
+ # Central layer/group tagging — one place to maintain, so the 20+ `@register`
81
+ # call sites stay clean. id -> (layer, group). Anything unlisted keeps the Check
82
+ # defaults (local, "").
83
+ TAGS: dict[str, tuple[str, str]] = {
84
+ # layout (G1 stranger-readiness)
85
+ "license-apache2": ("local", "layout"),
86
+ "readme-present": ("local", "layout"),
87
+ "readme-length": ("local", "layout"),
88
+ "readme-quickstart": ("local", "layout"),
89
+ "env-example": ("local", "layout"),
90
+ # secrets (Tier-1)
91
+ "no-tracked-secrets": ("local", "secrets"),
92
+ "gitleaks-clean": ("local", "secrets"),
93
+ # errors (G2)
94
+ "deep-health": ("local", "errors"),
95
+ "error-correlation-id": ("local", "errors"),
96
+ "error-path-tests": ("local", "errors"),
97
+ # supply-chain (G3)
98
+ "smoke-published": ("local", "supply-chain"),
99
+ "dependabot": ("local", "supply-chain"),
100
+ "actions-sha-pinned": ("local", "supply-chain"),
101
+ "lockfile-enforced": ("local", "supply-chain"),
102
+ "coverage-gate": ("local", "supply-chain"),
103
+ "version-matrix": ("local", "supply-chain"),
104
+ # evidence (G4)
105
+ "scorecard-workflow": ("local", "evidence"),
106
+ "scorecard-score": ("network", "evidence"),
107
+ "signed-releases-sbom": ("local", "evidence"),
108
+ "security-policy": ("local", "evidence"),
109
+ "changelog": ("local", "evidence"),
110
+ # doors (G5) + ai
111
+ "docs-index": ("local", "doors"),
112
+ "contributor-door": ("local", "doors"),
113
+ "code-of-conduct": ("local", "doors"),
114
+ "operator-door": ("local", "doors"),
115
+ "ai-door": ("local", "ai"),
116
+ "good-first-issues": ("network", "doors"),
117
+ # ai-native (M5)
118
+ "llms-no-secrets": ("local", "ai"),
119
+ "agent-scope-visibility": ("local", "ai"),
120
+ }
121
+
122
+
123
+ def _apply_tags() -> None:
124
+ for check, _ in REGISTRY:
125
+ check.layer, check.group = TAGS.get(check.id, (check.layer, check.group))
126
+
127
+
128
+ _apply_tags()
129
+
130
+ # Group/layer names the CLI exposes (for `invigil check <group>` / `--layer`).
131
+ GROUPS = sorted({g for _, g in TAGS.values()})
132
+ LAYERS = ("local", "network", "heavy")
@@ -0,0 +1,94 @@
1
+ """AI-native checks (group `ai`) — Invigil's answer to the architecture shift
2
+ toward autonomous agents and micro-context payloads (survival Threat 2).
3
+
4
+ Legacy linters are blind to `llms.txt` leaking a key or an agent wired to tools
5
+ with no declared scope. These are the statically-honest slice of the "agent blast
6
+ radius" idea: they don't compute *effective* IAM permissions (that needs
7
+ code→credential→policy correlation — out of scope here), they ensure the
8
+ preconditions for reasoning about blast radius exist: no secrets in the machine-
9
+ readable surface, and a declared tool inventory whenever an agent framework is used.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ from ..context import Context
17
+ from ..model import CheckResult, Status
18
+ from . import register
19
+
20
+ # High-signal secret patterns — specific enough not to fire on doc prose.
21
+ SECRET_PATTERNS = (
22
+ re.compile(r"AKIA[0-9A-Z]{16}"), # AWS access key id
23
+ re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
24
+ re.compile(r"sk-[A-Za-z0-9]{32,}"), # OpenAI-style secret key
25
+ re.compile(r"ghp_[A-Za-z0-9]{36}"), # GitHub PAT
26
+ re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), # Slack token
27
+ )
28
+
29
+ AI_FILES = ("llms.txt", "llms-full.txt", "AGENTS.md")
30
+
31
+ # Agent/LLM-framework import markers — presence means this repo builds agents.
32
+ AGENT_MARKERS = (
33
+ "langchain",
34
+ "langgraph",
35
+ "llama_index",
36
+ "llamaindex",
37
+ "import mcp",
38
+ "from mcp",
39
+ "autogen",
40
+ "crewai",
41
+ "semantic_kernel",
42
+ )
43
+
44
+
45
+ @register(
46
+ id="llms-no-secrets",
47
+ gate="G5",
48
+ title="Machine-readable surface leaks no secrets",
49
+ weight=1,
50
+ mandatory=False,
51
+ discipline="D5",
52
+ )
53
+ def llms_no_secrets(ctx: Context) -> CheckResult:
54
+ check = llms_no_secrets.__invigil__ # type: ignore[attr-defined]
55
+ present = [f for f in AI_FILES if ctx.exists(f)]
56
+ if not present:
57
+ return CheckResult(check, Status.SKIP, "no llms.txt / AGENTS.md to scan")
58
+ for f in present:
59
+ text = ctx.read(f)
60
+ for pat in SECRET_PATTERNS:
61
+ if pat.search(text):
62
+ return CheckResult(
63
+ check,
64
+ Status.FAIL,
65
+ f"{f} contains a secret-looking token",
66
+ f"remove the credential from {f} and rotate it — the AI surface is world-readable and crawled",
67
+ )
68
+ return CheckResult(check, Status.PASS, f"clean: {', '.join(present)}")
69
+
70
+
71
+ @register(
72
+ id="agent-scope-visibility",
73
+ gate="G5",
74
+ title="Agents declare their tool inventory (blast-radius precondition)",
75
+ weight=1,
76
+ mandatory=False,
77
+ discipline="D5",
78
+ )
79
+ def agent_scope_visibility(ctx: Context) -> CheckResult:
80
+ check = agent_scope_visibility.__invigil__ # type: ignore[attr-defined]
81
+ uses_agents = ctx.source_contains(*AGENT_MARKERS, suffixes=(".py", ".ts", ".js"))
82
+ if not uses_agents:
83
+ return CheckResult(check, Status.SKIP, "no agent framework in use")
84
+ # An AGENTS.md (or a tools manifest) is the declared inventory of what the
85
+ # agent can touch — the thing you'd reason about a blast radius from.
86
+ if ctx.first_existing("AGENTS.md", "agents.yaml", "tools.yaml", "docs/agents.md"):
87
+ return CheckResult(check, Status.PASS, "agent tool inventory declared")
88
+ return CheckResult(
89
+ check,
90
+ Status.FAIL,
91
+ "agent framework used but no declared tool inventory",
92
+ "add AGENTS.md listing each tool the agent can call and its permission scope "
93
+ "(the blast radius if the agent is prompt-injected)",
94
+ )
@@ -0,0 +1,114 @@
1
+ """G1 — a stranger succeeds in 10 minutes on a clean machine (Discipline D1).
2
+
3
+ These are the "is this even approachable?" checks: a real license, a landing-page
4
+ README that stays a landing page, a copy-paste quickstart, and an env-var config
5
+ surface instead of hardcoded values. Full G1 (the timed clean-machine boot) is
6
+ proven dynamically by the Stranger Gate; these are its static preconditions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ..context import Context
12
+ from ..model import CheckResult, Status
13
+ from . import register
14
+
15
+ README_MAX_LINES = 300
16
+
17
+
18
+ @register(id="license-apache2", gate="G1", title="LICENSE present and Apache-2.0", weight=2, discipline="D1")
19
+ def license_apache2(ctx: Context) -> CheckResult:
20
+ p = ctx.first_existing("LICENSE", "LICENSE.txt", "LICENSE.md")
21
+ check = license_apache2.__invigil__ # type: ignore[attr-defined]
22
+ if p is None:
23
+ return CheckResult(check, Status.FAIL, "no LICENSE file", "add an Apache-2.0 LICENSE at repo root")
24
+ text = p.read_text(errors="replace")
25
+ if "Apache License" in text and "Version 2.0" in text:
26
+ return CheckResult(check, Status.PASS, f"{p.name}: Apache-2.0")
27
+ return CheckResult(
28
+ check,
29
+ Status.FAIL,
30
+ f"{p.name} is not Apache-2.0",
31
+ "replace with the Apache-2.0 text (project standard); see https://apache.org/licenses/LICENSE-2.0.txt",
32
+ )
33
+
34
+
35
+ @register(id="readme-present", gate="G1", title="README exists", weight=1, discipline="D1")
36
+ def readme_present(ctx: Context) -> CheckResult:
37
+ check = readme_present.__invigil__ # type: ignore[attr-defined]
38
+ p = ctx.first_existing("README.md", "README.rst", "README")
39
+ if p:
40
+ return CheckResult(check, Status.PASS, p.name)
41
+ return CheckResult(check, Status.FAIL, "no README", "add a README.md landing page")
42
+
43
+
44
+ @register(
45
+ id="readme-length",
46
+ gate="G1",
47
+ title=f"README <= {README_MAX_LINES} lines (landing page, not the building)",
48
+ weight=1,
49
+ discipline="D1",
50
+ )
51
+ def readme_length(ctx: Context) -> CheckResult:
52
+ check = readme_length.__invigil__ # type: ignore[attr-defined]
53
+ p = ctx.first_existing("README.md", "README.rst", "README")
54
+ if p is None:
55
+ return CheckResult(check, Status.FAIL, "no README", "add a README.md")
56
+ n = len(p.read_text(errors="replace").splitlines())
57
+ if n <= README_MAX_LINES:
58
+ return CheckResult(check, Status.PASS, f"{n} lines")
59
+ return CheckResult(
60
+ check,
61
+ Status.FAIL,
62
+ f"{n} lines (> {README_MAX_LINES})",
63
+ f"move deep sections into doc/ and link them; target <= {README_MAX_LINES} lines",
64
+ )
65
+
66
+
67
+ @register(id="readme-quickstart", gate="G1", title="README has a Quick Start section", weight=1, discipline="D1")
68
+ def readme_quickstart(ctx: Context) -> CheckResult:
69
+ check = readme_quickstart.__invigil__ # type: ignore[attr-defined]
70
+ text = ctx.read("README.md").lower()
71
+ if any(h in text for h in ("## quick start", "## quickstart", "## getting started", "## install")):
72
+ return CheckResult(check, Status.PASS, "quickstart heading found")
73
+ return CheckResult(
74
+ check,
75
+ Status.FAIL,
76
+ "no quickstart heading",
77
+ 'add a "## Quick Start" section with <=5 copy-paste commands to first success',
78
+ )
79
+
80
+
81
+ @register(
82
+ id="env-example",
83
+ gate="G1",
84
+ title="Config is env-var driven (.env.example present)",
85
+ weight=1,
86
+ mandatory=False,
87
+ discipline="D1",
88
+ )
89
+ def env_example(ctx: Context) -> CheckResult:
90
+ check = env_example.__invigil__ # type: ignore[attr-defined]
91
+ if ctx.first_existing(".env.example", ".env.sample", ".env.template"):
92
+ return CheckResult(check, Status.PASS, ".env.example present")
93
+ # Only fail if the repo clearly reads runtime env config: a compose file, or
94
+ # source that actually pulls from the environment. A `config.py` that parses a
95
+ # local YAML is NOT a runtime env surface — don't false-positive on it.
96
+ # Match call sites, not bare identifiers. Skip Invigil's own package source
97
+ # (which names these patterns) — a scanned app never contains it except when
98
+ # Invigil grades itself, so this only prevents a self-trigger.
99
+ env_patterns = ("os.getenv(", "os.environ[", "os.environ.get", "(BaseSettings)")
100
+ reads_env = any(
101
+ m in p.read_text(errors="replace")
102
+ for p in ctx.rglob("**/*.py")
103
+ if "/invigil/" not in str(p)
104
+ for m in env_patterns
105
+ )
106
+ has_surface = ctx.exists("docker-compose.yml") or ctx.exists("compose.yml") or reads_env
107
+ if not has_surface:
108
+ return CheckResult(check, Status.SKIP, "no runtime env-config surface")
109
+ return CheckResult(
110
+ check,
111
+ Status.FAIL,
112
+ "config surface exists but no .env.example",
113
+ "add a .env.example documenting every env var; replace hardcoded values (e.g. localhost)",
114
+ )
@@ -0,0 +1,79 @@
1
+ """G2 — every failure mode tells the user the fix (Discipline D2).
2
+
3
+ Errors are a product surface: a deep-health/preflight surface that names missing
4
+ prerequisites before work starts, a global handler that returns a correlatable
5
+ id instead of a leaked traceback, and error-path tests written first. These are
6
+ detected heuristically from source; they're best-effort on non-Python stacks, so
7
+ the softer ones are non-mandatory.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ..context import Context
13
+ from ..model import CheckResult, Status
14
+ from . import register
15
+
16
+
17
+ @register(
18
+ id="deep-health", gate="G2", title="Deep-health / dependency-preflight surface exists", weight=1, discipline="D2"
19
+ )
20
+ def deep_health(ctx: Context) -> CheckResult:
21
+ check = deep_health.__invigil__ # type: ignore[attr-defined]
22
+ if not ctx.is_web_service():
23
+ return CheckResult(check, Status.SKIP, "not an HTTP service")
24
+ # A health route that inspects deps, or a dedicated sysdeps/preflight module.
25
+ has_module = bool(ctx.rglob("**/sysdeps.py") or ctx.rglob("**/preflight.py"))
26
+ has_route = ctx.source_contains('"/health"', "'/health'", "/api/system/deps", "health?deep")
27
+ if has_module or has_route:
28
+ return CheckResult(check, Status.PASS, "sysdeps/health surface found")
29
+ return CheckResult(
30
+ check,
31
+ Status.FAIL,
32
+ "no deep-health / preflight surface",
33
+ "add a /health?deep=1 endpoint (or a sysdeps.py) that reports missing prereqs with the install command",
34
+ )
35
+
36
+
37
+ @register(
38
+ id="error-correlation-id",
39
+ gate="G2",
40
+ title="Global handler returns a correlatable error id",
41
+ weight=1,
42
+ discipline="D2",
43
+ )
44
+ def error_correlation_id(ctx: Context) -> CheckResult:
45
+ check = error_correlation_id.__invigil__ # type: ignore[attr-defined]
46
+ if not ctx.is_web_service():
47
+ return CheckResult(check, Status.SKIP, "not an HTTP service")
48
+ has_handler = ctx.source_contains("exception_handler", "errorhandler", "add_exception_handler")
49
+ has_id = ctx.source_contains("error_id", "correlation_id", "request_id", "trace_id")
50
+ if has_handler and has_id:
51
+ return CheckResult(check, Status.PASS, "global handler + correlation id found")
52
+ return CheckResult(
53
+ check,
54
+ Status.FAIL,
55
+ "no global handler with a correlation id",
56
+ "add a global exception handler that logs a traceback under an error_id and returns that id (no leaked stack)",
57
+ )
58
+
59
+
60
+ @register(
61
+ id="error-path-tests",
62
+ gate="G2",
63
+ title="Error-path tests exist (write them first)",
64
+ weight=1,
65
+ mandatory=False,
66
+ discipline="D2",
67
+ )
68
+ def error_path_tests(ctx: Context) -> CheckResult:
69
+ check = error_path_tests.__invigil__ # type: ignore[attr-defined]
70
+ named = bool(ctx.rglob("**/test_error*.py") or ctx.rglob("**/*error*_test.go"))
71
+ tagged = ctx.source_contains("ERR-", suffixes=(".py", ".go", ".ts", ".js"))
72
+ if named or tagged:
73
+ return CheckResult(check, Status.PASS, "error-path tests found")
74
+ return CheckResult(
75
+ check,
76
+ Status.FAIL,
77
+ "no obvious error-path tests",
78
+ "add tests for the failure modes (e.g. tests/test_error_surfacing.py), ideally before the fix",
79
+ )
@@ -0,0 +1,120 @@
1
+ """G3 — machines watch what users won't report (Discipline D3).
2
+
3
+ Repo CI tests source; these checks prove the supply-chain gates that make quality
4
+ non-optional: a scheduled smoke-test of the *published* artifact, an enforced
5
+ lockfile, a coverage floor, SHA-pinned actions, a version matrix, and Dependabot.
6
+ All are read straight out of `.github/`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ from ..context import Context
14
+ from ..model import CheckResult, Status
15
+ from . import register
16
+
17
+ # `uses: owner/repo@REF` — a pin is a 40-hex SHA; anything else (tag/branch) is mutable.
18
+ _USES = re.compile(r"uses:\s*([^\s@]+)@([^\s#]+)")
19
+ _SHA = re.compile(r"^[0-9a-f]{40}$")
20
+
21
+
22
+ @register(
23
+ id="smoke-published", gate="G3", title="Scheduled smoke-test of the published artifact", weight=2, discipline="D3"
24
+ )
25
+ def smoke_published(ctx: Context) -> CheckResult:
26
+ check = smoke_published.__invigil__ # type: ignore[attr-defined]
27
+ for p in ctx.workflow_files():
28
+ text = p.read_text(errors="replace")
29
+ looks_smoke = "smoke" in p.name.lower() or "published" in text.lower() or "clean venv" in text.lower()
30
+ if looks_smoke and "schedule:" in text:
31
+ return CheckResult(check, Status.PASS, f"{p.name} runs on a schedule")
32
+ return CheckResult(
33
+ check,
34
+ Status.FAIL,
35
+ "no scheduled published-artifact smoke test",
36
+ "add a scheduled workflow (or `uses: invigil/invigil/.github/workflows/stranger-gate.yml@v1`) "
37
+ "that installs+boots the published artifact daily",
38
+ )
39
+
40
+
41
+ @register(id="dependabot", gate="G3", title="Dependabot configured", weight=1, discipline="D3")
42
+ def dependabot(ctx: Context) -> CheckResult:
43
+ check = dependabot.__invigil__ # type: ignore[attr-defined]
44
+ if ctx.first_existing(".github/dependabot.yml", ".github/dependabot.yaml") or ctx.exists("renovate.json"):
45
+ return CheckResult(check, Status.PASS, "dependabot/renovate present")
46
+ return CheckResult(
47
+ check,
48
+ Status.FAIL,
49
+ "no dependency update bot",
50
+ "add .github/dependabot.yml for your ecosystems (pip/npm/docker/github-actions)",
51
+ )
52
+
53
+
54
+ @register(
55
+ id="actions-sha-pinned",
56
+ gate="G3",
57
+ title="All GitHub Actions are SHA-pinned",
58
+ weight=1,
59
+ mandatory=False,
60
+ discipline="D3",
61
+ )
62
+ def actions_sha_pinned(ctx: Context) -> CheckResult:
63
+ check = actions_sha_pinned.__invigil__ # type: ignore[attr-defined]
64
+ unpinned: list[str] = []
65
+ for p in ctx.workflow_files():
66
+ for owner_repo, ref in _USES.findall(p.read_text(errors="replace")):
67
+ if owner_repo.startswith((".", "docker://")): # local/composite or docker refs are fine
68
+ continue
69
+ if not _SHA.match(ref):
70
+ unpinned.append(f"{owner_repo}@{ref}")
71
+ if not unpinned:
72
+ return CheckResult(check, Status.PASS, "all actions pinned to SHAs")
73
+ sample = ", ".join(sorted(set(unpinned))[:3])
74
+ return CheckResult(
75
+ check,
76
+ Status.FAIL,
77
+ f"{len(set(unpinned))} unpinned action(s): {sample}",
78
+ "pin every `uses:` to a 40-char commit SHA (keep the version in a trailing comment)",
79
+ )
80
+
81
+
82
+ @register(id="lockfile-enforced", gate="G3", title="Lockfile enforced in CI", weight=1, discipline="D3")
83
+ def lockfile_enforced(ctx: Context) -> CheckResult:
84
+ check = lockfile_enforced.__invigil__ # type: ignore[attr-defined]
85
+ text = ctx.workflows_text()
86
+ if any(tok in text for tok in ("--locked", "--frozen", "npm ci", "--frozen-lockfile", "go mod verify")):
87
+ return CheckResult(check, Status.PASS, "lockfile enforced")
88
+ return CheckResult(
89
+ check,
90
+ Status.FAIL,
91
+ "CI installs without enforcing the lockfile",
92
+ "use `uv sync --locked` / `npm ci` / `--frozen-lockfile` so CI fails on lockfile drift",
93
+ )
94
+
95
+
96
+ @register(id="coverage-gate", gate="G3", title="Coverage floor enforced in CI", weight=1, discipline="D3")
97
+ def coverage_gate(ctx: Context) -> CheckResult:
98
+ check = coverage_gate.__invigil__ # type: ignore[attr-defined]
99
+ text = ctx.workflows_text() + ctx.read("pyproject.toml") + ctx.read("setup.cfg")
100
+ if any(tok in text for tok in ("--cov-fail-under", "fail_under", "cov-fail-under", "-covermode")):
101
+ return CheckResult(check, Status.PASS, "coverage floor enforced")
102
+ return CheckResult(
103
+ check,
104
+ Status.FAIL,
105
+ "no coverage floor in CI",
106
+ "add `--cov-fail-under=<N>` (or equivalent) so a coverage drop fails the build",
107
+ )
108
+
109
+
110
+ @register(id="version-matrix", gate="G3", title="CI runs a version matrix", weight=1, mandatory=False, discipline="D3")
111
+ def version_matrix(ctx: Context) -> CheckResult:
112
+ check = version_matrix.__invigil__ # type: ignore[attr-defined]
113
+ if "matrix:" in ctx.workflows_text():
114
+ return CheckResult(check, Status.PASS, "matrix build present")
115
+ return CheckResult(
116
+ check,
117
+ Status.FAIL,
118
+ "no build matrix",
119
+ "add a matrix over the runtimes you claim to support (e.g. python 3.11 + 3.12)",
120
+ )
@@ -0,0 +1,109 @@
1
+ """G4 — supply-chain evidence is public (Discipline D3, the enterprise door).
2
+
3
+ A security-branded project is held to a higher bar, and meeting it publicly is
4
+ itself marketing: an OpenSSF Scorecard workflow (and a >=7 score), signed
5
+ releases with an SBOM, a security policy, and a changelog. The Scorecard score
6
+ is read live from scorecard.dev; if the repo isn't published there yet the check
7
+ degrades to SKIP rather than punishing an un-pushed repo.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import urllib.error
14
+ import urllib.request
15
+
16
+ from ..context import Context
17
+ from ..model import CheckResult, Status
18
+ from . import register
19
+
20
+ SCORECARD_MIN = 7.0
21
+
22
+
23
+ @register(id="scorecard-workflow", gate="G4", title="OpenSSF Scorecard workflow present", weight=1, discipline="D3")
24
+ def scorecard_workflow(ctx: Context) -> CheckResult:
25
+ check = scorecard_workflow.__invigil__ # type: ignore[attr-defined]
26
+ if "ossf/scorecard-action" in ctx.workflows_text() or ctx.first_existing(".github/workflows/scorecard.yml"):
27
+ return CheckResult(check, Status.PASS, "scorecard workflow present")
28
+ return CheckResult(
29
+ check,
30
+ Status.FAIL,
31
+ "no Scorecard workflow",
32
+ "add ossf/scorecard-action (scheduled) and publish the badge once >=7",
33
+ )
34
+
35
+
36
+ @register(
37
+ id="scorecard-score",
38
+ gate="G4",
39
+ title=f"OpenSSF Scorecard >= {SCORECARD_MIN:.0f}",
40
+ weight=1,
41
+ mandatory=False,
42
+ discipline="D3",
43
+ )
44
+ def scorecard_score(ctx: Context) -> CheckResult:
45
+ check = scorecard_score.__invigil__ # type: ignore[attr-defined]
46
+ slug = ctx.repo_slug()
47
+ if not slug:
48
+ return CheckResult(check, Status.SKIP, "no github remote to look up")
49
+ try:
50
+ url = f"https://api.scorecard.dev/projects/github.com/{slug}"
51
+ with urllib.request.urlopen(url, timeout=8) as resp: # noqa: S310 (fixed https host)
52
+ score = float(json.load(resp).get("score", 0))
53
+ except (urllib.error.URLError, TimeoutError, OSError):
54
+ # Network flake / not published yet: SKIP (excluded from the grade) so a
55
+ # timeout can never move the score. Resilience over a spurious downgrade.
56
+ return CheckResult(check, Status.SKIP, "scorecard.dev unreachable — excluded from grade")
57
+ except ValueError:
58
+ return CheckResult(check, Status.SKIP, "scorecard.dev returned no score yet")
59
+ if score >= SCORECARD_MIN:
60
+ return CheckResult(check, Status.PASS, f"score {score}")
61
+ return CheckResult(
62
+ check,
63
+ Status.FAIL,
64
+ f"score {score} < {SCORECARD_MIN:.0f}",
65
+ "triage the Scorecard findings (branch protection, token perms, pinned deps) to reach >=7",
66
+ )
67
+
68
+
69
+ @register(id="signed-releases-sbom", gate="G4", title="Releases are signed and ship an SBOM", weight=2, discipline="D3")
70
+ def signed_releases_sbom(ctx: Context) -> CheckResult:
71
+ check = signed_releases_sbom.__invigil__ # type: ignore[attr-defined]
72
+ text = ctx.workflows_text()
73
+ signed = "cosign" in text or "sigstore" in text
74
+ sbom = "syft" in text or "sbom" in text.lower() or "spdx" in text.lower()
75
+ if signed and sbom:
76
+ return CheckResult(check, Status.PASS, "cosign + SBOM in release workflow")
77
+ missing = ", ".join(m for m, ok in (("signing (cosign)", signed), ("SBOM (syft)", sbom)) if not ok)
78
+ return CheckResult(
79
+ check,
80
+ Status.FAIL,
81
+ f"release evidence missing: {missing}",
82
+ "sign release artifacts with cosign (keyless) and attach a syft SPDX SBOM",
83
+ )
84
+
85
+
86
+ @register(id="security-policy", gate="G4", title="SECURITY.md present", weight=1, discipline="D3")
87
+ def security_policy(ctx: Context) -> CheckResult:
88
+ check = security_policy.__invigil__ # type: ignore[attr-defined]
89
+ if ctx.first_existing("SECURITY.md", ".github/SECURITY.md", "docs/SECURITY.md"):
90
+ return CheckResult(check, Status.PASS, "SECURITY.md present")
91
+ return CheckResult(
92
+ check,
93
+ Status.FAIL,
94
+ "no SECURITY.md",
95
+ "add SECURITY.md with a private report channel and a supported-versions table",
96
+ )
97
+
98
+
99
+ @register(id="changelog", gate="G4", title="CHANGELOG.md present", weight=1, mandatory=False, discipline="D4")
100
+ def changelog(ctx: Context) -> CheckResult:
101
+ check = changelog.__invigil__ # type: ignore[attr-defined]
102
+ if ctx.first_existing("CHANGELOG.md", "CHANGELOG.rst", "docs/CHANGELOG.md"):
103
+ return CheckResult(check, Status.PASS, "CHANGELOG present")
104
+ return CheckResult(
105
+ check,
106
+ Status.FAIL,
107
+ "no CHANGELOG",
108
+ "keep a CHANGELOG.md (Keep a Changelog format) with honest caveats per release",
109
+ )