cbp-action 0.1.7__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.
- cbp/__init__.py +0 -0
- cbp/artifact_integrity.py +142 -0
- cbp/baseline.py +49 -0
- cbp/binary_gate.py +65 -0
- cbp/claims_gate.py +85 -0
- cbp/cli.py +148 -0
- cbp/comment_gate.py +69 -0
- cbp/config.py +139 -0
- cbp/coverage_gate.py +227 -0
- cbp/custom_rules.py +145 -0
- cbp/dependency_gate.py +76 -0
- cbp/docs_gate.py +28 -0
- cbp/encoding_gate.py +66 -0
- cbp/powershell_syntax.py +75 -0
- cbp/readers.py +64 -0
- cbp/regex_guard.py +71 -0
- cbp/sarif.py +79 -0
- cbp/source_hash.py +39 -0
- cbp/suppression.py +36 -0
- cbp/upgrade_gate.py +111 -0
- cbp/verify_release.py +66 -0
- cbp_action-0.1.7.dist-info/LICENSE +21 -0
- cbp_action-0.1.7.dist-info/METADATA +400 -0
- cbp_action-0.1.7.dist-info/RECORD +27 -0
- cbp_action-0.1.7.dist-info/WHEEL +5 -0
- cbp_action-0.1.7.dist-info/entry_points.txt +2 -0
- cbp_action-0.1.7.dist-info/top_level.txt +1 -0
cbp/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Release-artifact and Python environment integrity checks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
import zipfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .config import GateConfig
|
|
11
|
+
|
|
12
|
+
SKIP_PARTS = {".git", ".venv", "__pycache__", "node_modules", "wheelhouse", "qa-temp", "build"}
|
|
13
|
+
SKIP_PREFIXES = ("quality/", "checks/", "tests/", "tools/audit_", "tools/red_team", "tools/package_release", "tools/prune_", "tools/restructure_", "tools/rewrite_")
|
|
14
|
+
BAD_NAMES = {"netstat", "netstat.exe", "powershell", "powershell.exe", "pwsh", "pwsh.exe", "cmd", "cmd.exe"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def artifact_findings(cfg: GateConfig) -> list[str]:
|
|
18
|
+
"""Verify configured artifacts inside an optional package ZIP."""
|
|
19
|
+
required = getattr(cfg, "required_artifacts", ()) or ()
|
|
20
|
+
if not getattr(cfg, "package_zip", None):
|
|
21
|
+
return []
|
|
22
|
+
if not required:
|
|
23
|
+
return ["package_zip is configured but required_artifacts is empty"]
|
|
24
|
+
zip_path = Path(cfg.package_zip)
|
|
25
|
+
if not zip_path.is_file():
|
|
26
|
+
return [f"package zip missing: {zip_path}"]
|
|
27
|
+
findings: list[str] = []
|
|
28
|
+
try:
|
|
29
|
+
with zipfile.ZipFile(zip_path) as archive:
|
|
30
|
+
names = set(archive.namelist())
|
|
31
|
+
manifest: dict[str, str] = {}
|
|
32
|
+
if "update-manifest.json" in names:
|
|
33
|
+
import json
|
|
34
|
+
data = json.loads(archive.read("update-manifest.json").decode("utf-8"))
|
|
35
|
+
manifest = {str(item["path"]): str(item["sha256"]) for item in data.get("files", [])}
|
|
36
|
+
for item in required:
|
|
37
|
+
path = str(item.get("path")) if isinstance(item, dict) else str(item)
|
|
38
|
+
expected = str(item.get("sha256", "")) if isinstance(item, dict) else manifest.get(path, "")
|
|
39
|
+
if path not in names:
|
|
40
|
+
findings.append(f"required artifact missing from zip: {path}")
|
|
41
|
+
continue
|
|
42
|
+
if expected:
|
|
43
|
+
digest = hashlib.sha256(archive.read(path)).hexdigest()
|
|
44
|
+
if digest.lower() != expected.lower():
|
|
45
|
+
findings.append(f"required artifact hash mismatch: {path}")
|
|
46
|
+
except (OSError, zipfile.BadZipFile, ValueError) as exc:
|
|
47
|
+
return [f"cannot inspect package zip: {exc}"]
|
|
48
|
+
return findings
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_absolute_command(value: str) -> bool:
|
|
52
|
+
return value.startswith(("\\", "/")) or (len(value) > 2 and value[1] == ":")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
PATH_ESCAPE = None # line-based check below avoids regex escaping ambiguity
|
|
56
|
+
ACTION_REF = re.compile(r"uses:\s*([^\s#]+)")
|
|
57
|
+
FULL_SHA = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[0-9a-f]{40}$")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def path_escape_findings(cfg: GateConfig) -> list[str]:
|
|
61
|
+
"""Reject hard-coded Windows paths whose backslash becomes an escape."""
|
|
62
|
+
findings: list[str] = []
|
|
63
|
+
for path in list(cfg.root.rglob("*.py")) + list(cfg.root.rglob("*.ps1")):
|
|
64
|
+
if SKIP_PARTS & set(path.parts):
|
|
65
|
+
continue
|
|
66
|
+
for line_no, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
|
|
67
|
+
if "C:\\" in line and any(token in line for token in ("\t", "\n", "\r")):
|
|
68
|
+
findings.append(f"{path.relative_to(cfg.root).as_posix()}:{line_no}: ambiguous Windows path escape; use os.path.join or escaped slashes")
|
|
69
|
+
return findings
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def python_env_findings(cfg: GateConfig) -> list[str]:
|
|
73
|
+
"""Reject obvious PATH-dependent subprocess calls and missing sys.path guards."""
|
|
74
|
+
findings: list[str] = []
|
|
75
|
+
for path in cfg.root.rglob("*.py"):
|
|
76
|
+
rel = path.relative_to(cfg.root).as_posix()
|
|
77
|
+
if SKIP_PARTS & set(path.parts) or rel.startswith(SKIP_PREFIXES):
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
81
|
+
except (OSError, SyntaxError):
|
|
82
|
+
continue
|
|
83
|
+
for node in ast.walk(tree):
|
|
84
|
+
if not isinstance(node, ast.Call) or not node.args:
|
|
85
|
+
continue
|
|
86
|
+
func = node.func
|
|
87
|
+
name = ""
|
|
88
|
+
if isinstance(func, ast.Attribute):
|
|
89
|
+
name = func.attr
|
|
90
|
+
if name not in {"run", "Popen", "call", "check_output", "system"}:
|
|
91
|
+
continue
|
|
92
|
+
first = node.args[0]
|
|
93
|
+
command = ""
|
|
94
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
95
|
+
command = first.value
|
|
96
|
+
elif isinstance(first, (ast.List, ast.Tuple)) and first.elts:
|
|
97
|
+
head = first.elts[0]
|
|
98
|
+
if isinstance(head, ast.Constant) and isinstance(head.value, str):
|
|
99
|
+
command = head.value
|
|
100
|
+
if command:
|
|
101
|
+
token = command.strip().split()[0] if command.strip() else ""
|
|
102
|
+
base = Path(token).name.lower()
|
|
103
|
+
if base in BAD_NAMES and not _is_absolute_command(token):
|
|
104
|
+
findings.append(f"{path.relative_to(cfg.root).as_posix()}:{node.lineno}: naked external command {token!r}")
|
|
105
|
+
for name in ("tools/broker.py", "webui/run.py"):
|
|
106
|
+
path = cfg.root / name
|
|
107
|
+
if path.is_file() and "sys.path.insert" not in path.read_text(encoding="utf-8"):
|
|
108
|
+
findings.append(f"{name}: missing sys.path.insert bootstrap")
|
|
109
|
+
for path in list(cfg.root.rglob("*.bat")) + list(cfg.root.rglob("*.ps1")):
|
|
110
|
+
if SKIP_PARTS & set(path.parts):
|
|
111
|
+
continue
|
|
112
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
113
|
+
for line in text.splitlines():
|
|
114
|
+
if "PYTHONPATH" in line and "=" in line:
|
|
115
|
+
if "tools" not in line:
|
|
116
|
+
findings.append(f"{path.relative_to(cfg.root).as_posix()}: PYTHONPATH must include tools/")
|
|
117
|
+
break
|
|
118
|
+
return findings
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def action_pin_findings(cfg: GateConfig) -> list[str]:
|
|
122
|
+
"""Require every GitHub Action use to be pinned to a full commit SHA."""
|
|
123
|
+
findings: list[str] = []
|
|
124
|
+
for path in list(cfg.root.rglob("*.yml")) + list(cfg.root.rglob("*.yaml")):
|
|
125
|
+
rel = path.relative_to(cfg.root).as_posix()
|
|
126
|
+
if SKIP_PARTS & set(path.parts) or rel.endswith("usage.yml") or rel.startswith(("examples/", "docs/")) or "/examples/" in rel or "/docs/" in rel:
|
|
127
|
+
continue
|
|
128
|
+
for line_no, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
|
|
129
|
+
match = ACTION_REF.search(line)
|
|
130
|
+
if not match:
|
|
131
|
+
continue
|
|
132
|
+
ref = match.group(1).strip("'\"")
|
|
133
|
+
if ref.startswith("./") or ref.startswith("docker://"):
|
|
134
|
+
continue
|
|
135
|
+
if not FULL_SHA.fullmatch(ref):
|
|
136
|
+
findings.append(f"{path.relative_to(cfg.root).as_posix()}:{line_no}: GitHub Action is not pinned to a full SHA: {ref}")
|
|
137
|
+
return findings
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def check(cfg: GateConfig) -> list[str]:
|
|
141
|
+
"""Run both release-integrity checks."""
|
|
142
|
+
return artifact_findings(cfg) + python_env_findings(cfg) + path_escape_findings(cfg) + action_pin_findings(cfg)
|
cbp/baseline.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Baseline fingerprints for pre-existing findings."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
SCHEMA = "cbp-baseline/v1"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def fingerprint(gate: str, line: str) -> str:
|
|
12
|
+
return hashlib.sha256((gate + "\0" + line.strip()).encode("utf-8")).hexdigest()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load(path: Path | str | None) -> set[str]:
|
|
16
|
+
if not path:
|
|
17
|
+
return set()
|
|
18
|
+
p = Path(path)
|
|
19
|
+
if not p.is_file():
|
|
20
|
+
return set()
|
|
21
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
22
|
+
return {str(item["fingerprint"]) for item in data.get("entries", []) if isinstance(item, dict) and item.get("fingerprint")}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write(path: Path, gate: str, lines: list[str]) -> int:
|
|
26
|
+
entries = [{"gate": gate, "fingerprint": fingerprint(gate, line), "line": line} for line in lines]
|
|
27
|
+
data = {"schema": SCHEMA, "version": 1, "entries": entries}
|
|
28
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
29
|
+
return len(entries)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def filter_lines(cfg, gate: str, lines: list[str]) -> list[str]:
|
|
33
|
+
configured = getattr(cfg, "baseline", "")
|
|
34
|
+
path = cfg.path(configured) if configured else cfg.root / "cbp-baseline.json"
|
|
35
|
+
allowed = load(path)
|
|
36
|
+
return [line for line in lines if fingerprint(gate, line) not in allowed]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_entries(path: Path, entries: list[tuple[str, str]]) -> int:
|
|
40
|
+
data = {
|
|
41
|
+
"schema": SCHEMA,
|
|
42
|
+
"version": 1,
|
|
43
|
+
"entries": [
|
|
44
|
+
{"gate": gate, "fingerprint": fingerprint(gate, line), "line": line}
|
|
45
|
+
for gate, line in entries
|
|
46
|
+
],
|
|
47
|
+
}
|
|
48
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + chr(10), encoding="utf-8")
|
|
49
|
+
return len(data["entries"])
|
cbp/binary_gate.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Generic large-binary provenance gate."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .config import GateConfig
|
|
10
|
+
|
|
11
|
+
SUFFIXES = (".exe", ".dll", ".bin", ".so", ".dylib", ".whl", ".tar.gz")
|
|
12
|
+
SUPPORTED_SCOPES = {"self-contained-runtime", "runtime-pack", "runtime-only"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def info_size(archive, name):
|
|
16
|
+
"""Return a ZIP member's uncompressed size."""
|
|
17
|
+
return archive.getinfo(name).file_size
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _candidates(cfg: GateConfig):
|
|
21
|
+
package = cfg.path(cfg.package_zip) if cfg.package_zip else None
|
|
22
|
+
if package and package.is_file():
|
|
23
|
+
with zipfile.ZipFile(package) as archive:
|
|
24
|
+
return [(name, info_size(archive, name), archive.read(name)) for name in archive.namelist() if name.lower().endswith(SUFFIXES) and info_size(archive, name) > cfg.binary_limit_mb * 1024 * 1024]
|
|
25
|
+
result = []
|
|
26
|
+
for path in cfg.root.rglob("*"):
|
|
27
|
+
if path.is_file() and path.name.lower().endswith(SUFFIXES) and path.stat().st_size > cfg.binary_limit_mb * 1024 * 1024:
|
|
28
|
+
text = path.as_posix()
|
|
29
|
+
if (
|
|
30
|
+
".git" not in path.parts
|
|
31
|
+
and ".venv" not in path.parts
|
|
32
|
+
and "wheelhouse" not in path.parts
|
|
33
|
+
and "build/bin" not in text
|
|
34
|
+
and "build/obj" not in text
|
|
35
|
+
and "build/publish" not in text
|
|
36
|
+
):
|
|
37
|
+
result.append((path.name, path.stat().st_size, path.read_bytes()))
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run(cfg: GateConfig) -> tuple[bool, str]:
|
|
42
|
+
"""Require provenance and SBOM records for large release binaries."""
|
|
43
|
+
candidates = _candidates(cfg)
|
|
44
|
+
for name, size, data in candidates:
|
|
45
|
+
artifacts = cfg.path(cfg.binary_artifacts)
|
|
46
|
+
build = artifacts / (Path(name).name + ".build.json")
|
|
47
|
+
sbom = artifacts / (Path(name).name + ".sbom.json")
|
|
48
|
+
if not build.is_file() or not sbom.is_file():
|
|
49
|
+
return False, f"{name} lacks build/SBOM provenance"
|
|
50
|
+
sbom_record = json.loads(sbom.read_text(encoding="utf-8"))
|
|
51
|
+
scope = sbom_record.get("scope")
|
|
52
|
+
if scope is not None and scope not in SUPPORTED_SCOPES:
|
|
53
|
+
return False, f"{name} has unsupported SBOM scope {scope!r}"
|
|
54
|
+
if sbom_record.get("binary_sha256") and sbom_record["binary_sha256"].upper() != hashlib.sha256(data).hexdigest().upper():
|
|
55
|
+
return False, f"{name} SBOM binary hash mismatch"
|
|
56
|
+
if "binary_size" in sbom_record and int(sbom_record["binary_size"]) != size:
|
|
57
|
+
return False, f"{name} SBOM binary size mismatch"
|
|
58
|
+
record = json.loads(build.read_text(encoding="utf-8"))
|
|
59
|
+
if record.get("sha256", "").upper() != hashlib.sha256(data).hexdigest().upper():
|
|
60
|
+
return False, f"{name} provenance hash mismatch"
|
|
61
|
+
if int(record.get("size_bytes", -1)) != size:
|
|
62
|
+
return False, f"{name} provenance size mismatch"
|
|
63
|
+
if not candidates:
|
|
64
|
+
return True, "N/A (no binaries found)"
|
|
65
|
+
return True, f"binaries={len(candidates)}"
|
cbp/claims_gate.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Generic release-claim reconciliation gate."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
RULE_ID = "CBP-CLM-001"
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .config import GateConfig
|
|
11
|
+
|
|
12
|
+
TOKEN = re.compile(r"(?m)^QUALITY_COUNTS:\s*(\d+)/(\d+)\s*$")
|
|
13
|
+
GATE_CLAIM = re.compile(r"\b(?:all|across|through)\s+([A-Za-z]+|\d+)\s+gates?\b", re.IGNORECASE)
|
|
14
|
+
WORDS = {
|
|
15
|
+
word: number
|
|
16
|
+
for number, word in enumerate(
|
|
17
|
+
("zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten")
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _gate_number(text: str, total: int, *, required: bool) -> str:
|
|
23
|
+
match = GATE_CLAIM.search(text)
|
|
24
|
+
if not match:
|
|
25
|
+
return "release body must state the gate count" if required else ""
|
|
26
|
+
token = match.group(1).lower()
|
|
27
|
+
number = int(token) if token.isdigit() else WORDS.get(token)
|
|
28
|
+
if number is None:
|
|
29
|
+
return f"unknown gate count claim: {match.group(0)}"
|
|
30
|
+
if number != total:
|
|
31
|
+
return f"notes claim {token} gates but manifest has {total}"
|
|
32
|
+
return ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _body_problem(path: Path, expected: str, total: int) -> str:
|
|
36
|
+
if not path.is_file():
|
|
37
|
+
return f"release body missing: {path}"
|
|
38
|
+
text = path.read_text(encoding="utf-8")
|
|
39
|
+
tokens = TOKEN.findall(text)
|
|
40
|
+
if len(tokens) != 1:
|
|
41
|
+
return "release body needs exactly one QUALITY_COUNTS line"
|
|
42
|
+
actual = f"{tokens[0][0]}/{tokens[0][1]}"
|
|
43
|
+
if actual != expected:
|
|
44
|
+
return f"release body {actual} != manifest {expected}"
|
|
45
|
+
problem = _gate_number(text, total, required=True)
|
|
46
|
+
if problem:
|
|
47
|
+
return "release body " + problem
|
|
48
|
+
if "First public release" in text:
|
|
49
|
+
return "release body must not claim First public release"
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run(cfg: GateConfig) -> tuple[bool, str]:
|
|
54
|
+
"""Match release-note quality claims to the machine manifest."""
|
|
55
|
+
if not cfg.claims_manifest or not cfg.claims_notes:
|
|
56
|
+
return False, "claims_manifest and claims_notes must be configured"
|
|
57
|
+
manifest = cfg.path(cfg.claims_manifest)
|
|
58
|
+
notes = cfg.path(cfg.claims_notes)
|
|
59
|
+
if not manifest.is_file():
|
|
60
|
+
return False, (
|
|
61
|
+
"quality-manifest.json missing. "
|
|
62
|
+
"Run `python tools/dogfood_manifest.py --root .` first to generate a local manifest."
|
|
63
|
+
)
|
|
64
|
+
if not notes.is_file():
|
|
65
|
+
return False, "release notes missing. Run the dogfood command first to generate them."
|
|
66
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
67
|
+
counts = data.get("counts", {})
|
|
68
|
+
expected = f"{counts.get('passed', -1)}/{counts.get('total', -1)}"
|
|
69
|
+
text = notes.read_text(encoding="utf-8")
|
|
70
|
+
tokens = TOKEN.findall(text)
|
|
71
|
+
if len(tokens) != 1:
|
|
72
|
+
return False, "release notes need exactly one QUALITY_COUNTS line"
|
|
73
|
+
actual = f"{tokens[0][0]}/{tokens[0][1]}"
|
|
74
|
+
if actual != expected:
|
|
75
|
+
return False, f"notes {actual} != manifest {expected}"
|
|
76
|
+
total = int(counts.get("total", -1))
|
|
77
|
+
gate_problem = _gate_number(text, total, required=False)
|
|
78
|
+
if gate_problem:
|
|
79
|
+
return False, gate_problem
|
|
80
|
+
body = getattr(cfg, "release_body", "")
|
|
81
|
+
if body:
|
|
82
|
+
body_problem = _body_problem(cfg.path(body), expected, total)
|
|
83
|
+
if body_problem:
|
|
84
|
+
return False, body_problem
|
|
85
|
+
return True, f"claims={actual}"
|
cbp/cli.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Standalone CBP CLI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import baseline, binary_gate, suppression, claims_gate, comment_gate, coverage_gate, custom_rules, dependency_gate, docs_gate, encoding_gate, sarif, upgrade_gate, verify_release
|
|
11
|
+
from .config import load_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv: list[str] | None = None) -> int:
|
|
15
|
+
"""Run enabled CBP gates and return a process exit code."""
|
|
16
|
+
parser = argparse.ArgumentParser(prog="cbp")
|
|
17
|
+
parser.add_argument("--root", default=".")
|
|
18
|
+
parser.add_argument("--config", default="")
|
|
19
|
+
parser.add_argument("--mode", choices=("audit", "warn", "enforce"), default="enforce")
|
|
20
|
+
parser.add_argument("--coverage-threshold", type=float, default=None)
|
|
21
|
+
parser.add_argument("--policy-coverage-threshold", type=float, default=None)
|
|
22
|
+
parser.add_argument("--coverage-json", default="")
|
|
23
|
+
parser.add_argument("--coverage-allowlist", default="")
|
|
24
|
+
parser.add_argument("--coverage-signature", default="")
|
|
25
|
+
parser.add_argument("--require-coverage-signature", type=lambda x: x.lower() == "true", default=None)
|
|
26
|
+
parser.add_argument("--dependency-lock", default="")
|
|
27
|
+
parser.add_argument("--install-command", default="")
|
|
28
|
+
parser.add_argument("--installer-core", dest="install_command", default=argparse.SUPPRESS)
|
|
29
|
+
parser.add_argument("--docs-manifest", default="")
|
|
30
|
+
parser.add_argument("--binary-artifacts", default="")
|
|
31
|
+
parser.add_argument("--package-zip", default="")
|
|
32
|
+
parser.add_argument("--claims-manifest", default="")
|
|
33
|
+
parser.add_argument("--claims-notes", default="")
|
|
34
|
+
parser.add_argument("--release-body", default="")
|
|
35
|
+
parser.add_argument("--manifest", default="")
|
|
36
|
+
parser.add_argument("--sarif-output", default="")
|
|
37
|
+
parser.add_argument("--baseline", default="")
|
|
38
|
+
parser.add_argument("--generate-baseline", default="")
|
|
39
|
+
parser.add_argument("--verify-release", default="")
|
|
40
|
+
parser.add_argument("--repository", default="")
|
|
41
|
+
parser.add_argument("--enable-coverage", type=lambda x: x.lower() == "true", default=None)
|
|
42
|
+
parser.add_argument("--enable-encoding", type=lambda x: x.lower() == "true", default=None)
|
|
43
|
+
parser.add_argument("--enable-dependency", type=lambda x: x.lower() == "true", default=None)
|
|
44
|
+
parser.add_argument("--enable-comments", type=lambda x: x.lower() == "true", default=None)
|
|
45
|
+
parser.add_argument("--enable-docs", type=lambda x: x.lower() == "true", default=None)
|
|
46
|
+
parser.add_argument("--enable-binary", type=lambda x: x.lower() == "true", default=None)
|
|
47
|
+
parser.add_argument("--enable-claims", type=lambda x: x.lower() == "true", default=None)
|
|
48
|
+
parser.add_argument("--enable-custom", type=lambda x: x.lower() == "true", default=None)
|
|
49
|
+
parser.add_argument("--enable-upgrade", type=lambda x: x.lower() == "true", default=None)
|
|
50
|
+
args = parser.parse_args(argv)
|
|
51
|
+
if args.verify_release:
|
|
52
|
+
repository = args.repository or os.environ.get("GITHUB_REPOSITORY", "")
|
|
53
|
+
if not repository:
|
|
54
|
+
print("VERIFY_RELEASE_FAILED: --repository or GITHUB_REPOSITORY is required")
|
|
55
|
+
return 1
|
|
56
|
+
body = Path(args.release_body) if args.release_body else Path(args.root) / "docs" / "RELEASE_BODY.md"
|
|
57
|
+
ok, detail = verify_release.verify(repository, args.verify_release, body, verify_release.token_from_env())
|
|
58
|
+
print(("RELEASE_BODY_OK " if ok else "") + detail)
|
|
59
|
+
return 0 if ok else 1
|
|
60
|
+
config_path = Path(args.config) if args.config else Path(args.root) / "cbp.json"
|
|
61
|
+
if not args.config and not config_path.is_file():
|
|
62
|
+
config_path = Path(args.root) / ".cbp" / "config.json"
|
|
63
|
+
overrides = {}
|
|
64
|
+
if args.coverage_threshold is not None:
|
|
65
|
+
overrides["coverage_threshold"] = args.coverage_threshold
|
|
66
|
+
if args.policy_coverage_threshold is not None:
|
|
67
|
+
overrides["policy_threshold"] = args.policy_coverage_threshold
|
|
68
|
+
for key, value in {
|
|
69
|
+
"coverage_json": args.coverage_json, "coverage_allowlist": args.coverage_allowlist, "coverage_signature": args.coverage_signature,
|
|
70
|
+
"dependency_lock": args.dependency_lock, "install_command": args.install_command,
|
|
71
|
+
"docs_manifest": args.docs_manifest, "binary_artifacts": args.binary_artifacts,
|
|
72
|
+
"package_zip": args.package_zip, "claims_manifest": args.claims_manifest,
|
|
73
|
+
"claims_notes": args.claims_notes, "release_body": args.release_body, "baseline": args.baseline,
|
|
74
|
+
}.items():
|
|
75
|
+
if value:
|
|
76
|
+
overrides[key] = value
|
|
77
|
+
if args.manifest:
|
|
78
|
+
overrides["claims_manifest"] = args.manifest
|
|
79
|
+
for key in ("enable_coverage", "require_coverage_signature", "enable_encoding", "enable_dependency", "enable_comments", "enable_docs", "enable_binary", "enable_claims", "enable_custom", "enable_upgrade"):
|
|
80
|
+
value = getattr(args, key)
|
|
81
|
+
if value is not None:
|
|
82
|
+
overrides[key] = value
|
|
83
|
+
cfg = load_config(Path(args.root), config_path, overrides)
|
|
84
|
+
checks = []
|
|
85
|
+
if cfg.enable_encoding:
|
|
86
|
+
checks.append(("encoding", encoding_gate.run))
|
|
87
|
+
if cfg.enable_coverage:
|
|
88
|
+
checks.append(("coverage", coverage_gate.run))
|
|
89
|
+
if cfg.enable_dependency:
|
|
90
|
+
checks.append(("dependency", dependency_gate.run))
|
|
91
|
+
if cfg.enable_comments:
|
|
92
|
+
checks.append(("comments", comment_gate.run))
|
|
93
|
+
if cfg.enable_docs:
|
|
94
|
+
checks.append(("docs", docs_gate.run))
|
|
95
|
+
if cfg.enable_claims:
|
|
96
|
+
checks.append(("claims", claims_gate.run))
|
|
97
|
+
if cfg.enable_custom:
|
|
98
|
+
checks.append(("custom", custom_rules.run))
|
|
99
|
+
if cfg.enable_upgrade:
|
|
100
|
+
checks.append(("upgrade", upgrade_gate.run))
|
|
101
|
+
if cfg.enable_binary:
|
|
102
|
+
checks.append(("binary", binary_gate.run))
|
|
103
|
+
ok = True
|
|
104
|
+
gate_results = []
|
|
105
|
+
for name, check in checks:
|
|
106
|
+
passed, detail = check(cfg)
|
|
107
|
+
filtered = suppression.filter_text(cfg.root, detail)
|
|
108
|
+
if filtered != detail:
|
|
109
|
+
passed = passed or not filtered.strip()
|
|
110
|
+
detail = filtered or "inline-suppressed"
|
|
111
|
+
gate_results.append((name, passed, detail))
|
|
112
|
+
print(f"{name}: {'PASS' if passed else 'FAIL'} {detail}")
|
|
113
|
+
ok = ok and passed
|
|
114
|
+
if not checks:
|
|
115
|
+
ok = False
|
|
116
|
+
baseline_path = Path(args.generate_baseline) if args.generate_baseline else Path(args.baseline) if args.baseline else Path(args.root) / "cbp-baseline.json"
|
|
117
|
+
if args.generate_baseline:
|
|
118
|
+
entries = []
|
|
119
|
+
for gate, passed, detail in gate_results:
|
|
120
|
+
if not passed:
|
|
121
|
+
entries.extend((gate, line) for line in detail.splitlines() if line.strip())
|
|
122
|
+
baseline.write_entries(baseline_path, entries)
|
|
123
|
+
print(f"BASELINE_WRITTEN path={baseline_path} entries={len(entries)}")
|
|
124
|
+
return 0
|
|
125
|
+
allowed_baseline = baseline.load(baseline_path)
|
|
126
|
+
if allowed_baseline:
|
|
127
|
+
adjusted = []
|
|
128
|
+
for gate, passed, detail in gate_results:
|
|
129
|
+
kept = [line for line in detail.splitlines() if baseline.fingerprint(gate, line) not in allowed_baseline]
|
|
130
|
+
if not passed and not any(line.strip() for line in kept):
|
|
131
|
+
passed = True
|
|
132
|
+
adjusted.append((gate, passed, chr(10).join(kept) if kept else "baseline-suppressed"))
|
|
133
|
+
gate_results = adjusted
|
|
134
|
+
ok = all(passed for _, passed, _ in gate_results)
|
|
135
|
+
if args.sarif_output:
|
|
136
|
+
try:
|
|
137
|
+
target = Path(args.sarif_output)
|
|
138
|
+
document = sarif.build(gate_results, Path(args.root))
|
|
139
|
+
target.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
|
|
140
|
+
except OSError as exc:
|
|
141
|
+
print(f"sarif: FAIL cannot write {args.sarif_output}: {exc}")
|
|
142
|
+
ok = False
|
|
143
|
+
print("CBP_RESULT=" + ("PASS" if ok else "FAIL") + " gates_ran=" + str(len(checks)))
|
|
144
|
+
return 0 if ok or args.mode in {"audit", "warn"} else 1
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
raise SystemExit(main())
|
cbp/comment_gate.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Generic AST/token comment gate."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
RULE_ID = "CBP-CMT-001"
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
import re
|
|
8
|
+
import tokenize
|
|
9
|
+
|
|
10
|
+
from .config import GateConfig
|
|
11
|
+
from .baseline import filter_lines
|
|
12
|
+
|
|
13
|
+
SLOP = re.compile(r"#\s*(This is|It is|This function|This method|This class)\b", re.IGNORECASE)
|
|
14
|
+
SUPPRESS = re.compile(r"cbp:\s*ignore=(CBP-[A-Z]+-\d{3})", re.IGNORECASE)
|
|
15
|
+
GENERIC = ("caller-supplied value", "function result, or none", "helper/type")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _suppressed(lines: list[str], lineno: int, rule: str) -> bool:
|
|
19
|
+
if lineno < 1 or lineno > len(lines):
|
|
20
|
+
return False
|
|
21
|
+
line = lines[lineno - 1]
|
|
22
|
+
match = SUPPRESS.search(line)
|
|
23
|
+
return bool(match and match.group(1).upper() == rule.upper())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _check(path, findings):
|
|
27
|
+
text = path.read_text(encoding="utf-8")
|
|
28
|
+
lines = text.splitlines()
|
|
29
|
+
tree = ast.parse(text)
|
|
30
|
+
for node in ast.walk(tree):
|
|
31
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
32
|
+
continue
|
|
33
|
+
name = getattr(node, "name", "")
|
|
34
|
+
if name.startswith("_"):
|
|
35
|
+
continue
|
|
36
|
+
doc = ast.get_docstring(node) or ""
|
|
37
|
+
if len(doc.strip()) < 20:
|
|
38
|
+
if not _suppressed(lines, node.lineno, RULE_ID):
|
|
39
|
+
findings.append(f"{path.name}:{node.lineno}: public {name} lacks substantive docstring")
|
|
40
|
+
continue
|
|
41
|
+
if any(phrase in doc.casefold() for phrase in GENERIC):
|
|
42
|
+
if not _suppressed(lines, node.lineno, RULE_ID):
|
|
43
|
+
findings.append(f"{path.name}:{node.lineno}: public {name} docstring is generic boilerplate")
|
|
44
|
+
args = [a.arg for a in node.args.args if a.arg not in {"self", "cls"}] if hasattr(node, "args") else []
|
|
45
|
+
if "Args:" in doc or "Parameters:" in doc:
|
|
46
|
+
for arg in args:
|
|
47
|
+
if arg not in doc and not _suppressed(lines, node.lineno, RULE_ID):
|
|
48
|
+
findings.append(f"{path.name}:{node.lineno}: docstring misses {arg}")
|
|
49
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
50
|
+
for token in tokenize.generate_tokens(handle.readline):
|
|
51
|
+
if token.type == tokenize.COMMENT and SLOP.search(token.string):
|
|
52
|
+
if not _suppressed(lines, token.start[0], RULE_ID):
|
|
53
|
+
findings.append(f"{path.name}:{token.start[0]}: slop comment {token.string.strip()}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run(cfg: GateConfig) -> tuple[bool, str]:
|
|
57
|
+
"""Reject missing docstrings, argument mismatches, and slop comments."""
|
|
58
|
+
findings: list[str] = []
|
|
59
|
+
for relative in cfg.comment_files:
|
|
60
|
+
path = cfg.path(relative)
|
|
61
|
+
if path.is_file():
|
|
62
|
+
try:
|
|
63
|
+
_check(path, findings)
|
|
64
|
+
except SyntaxError as exc:
|
|
65
|
+
findings.append(f"{path.name}: parse failed: {exc}")
|
|
66
|
+
findings = filter_lines(cfg, "comments", findings)
|
|
67
|
+
if findings:
|
|
68
|
+
return False, "\n".join(findings[:80]) + f"\nfindings={len(findings)}"
|
|
69
|
+
return True, f"comments={len(cfg.comment_files)} files (balanced against baseline)"
|