proofctl 0.1.11__tar.gz → 0.2.1__tar.gz
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.
- {proofctl-0.1.11 → proofctl-0.2.1}/PKG-INFO +1 -1
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/dockerfile.py +39 -13
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/placeholders.py +0 -2
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/quality.py +7 -3
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/shell_checker.py +37 -5
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/terraform.py +9 -6
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/yaml_checker.py +62 -53
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/cli.py +25 -1
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/config.py +59 -3
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/engine.py +18 -2
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/models.py +1 -1
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/reporters/html_reporter.py +1 -1
- proofctl-0.2.1/proofctl/reporters/json_reporter.py +63 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/reporters/terminal.py +17 -4
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl.egg-info/PKG-INFO +1 -1
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl.egg-info/SOURCES.txt +2 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/pyproject.toml +1 -1
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_config_proofctl.py +31 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_dockerfile.py +6 -3
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_engine.py +68 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_phase1_audit_fixes.py +6 -2
- proofctl-0.2.1/tests/test_phase6_audit_fixes.py +359 -0
- proofctl-0.2.1/tests/test_phase8_audit_fixes.py +328 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_terraform_ext.py +30 -0
- proofctl-0.1.11/proofctl/reporters/json_reporter.py +0 -22
- {proofctl-0.1.11 → proofctl-0.2.1}/LICENSE +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/README.md +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/__init__.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/_globmatch.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/baseline.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/__init__.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/base.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/hcl_utils.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/imports.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/leakage.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/llm_integration.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/security.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/terragrunt.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/checkers/variants.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/fixer.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/registry.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl/reporters/__init__.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl.egg-info/dependency_links.txt +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl.egg-info/entry_points.txt +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl.egg-info/requires.txt +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/proofctl.egg-info/top_level.txt +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/setup.cfg +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_baseline.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_fixer.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_imports.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_leakage.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_llm.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_local_packages.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_phase2_audit_fixes.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_phase3_audit_fixes.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_phase4_audit_fixes.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_phase5_llm_imports_fixes.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_phase5_p_l_v_fixes.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_phase5_quality_fixes.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_placeholders.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_quality.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_quality_ext.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_security.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_security_ext.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_suppression.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_terraform.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_terragrunt.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_variants.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_yaml.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.1}/tests/test_yaml_k8s_gha.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proofctl
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Zero-dependency linter for Python, Terraform, Dockerfiles, Kubernetes, and GitHub Actions — catches AI slop and security misconfigurations pre-commit
|
|
5
5
|
Author-email: Kolawolu Odunola <kolawolu.o@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -9,7 +9,7 @@ from .base import DockerfileChecker
|
|
|
9
9
|
|
|
10
10
|
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
11
11
|
|
|
12
|
-
_SHA_DIGEST_RE = re.compile(r"@
|
|
12
|
+
_SHA_DIGEST_RE = re.compile(r"@sha(?:256:[a-f0-9]{64}|384:[a-f0-9]{96}|512:[a-f0-9]{128})")
|
|
13
13
|
_HAS_TAG_RE = re.compile(r":[a-zA-Z0-9][\w.\-]*$")
|
|
14
14
|
|
|
15
15
|
# DF-007: curl/wget piped to a shell
|
|
@@ -330,16 +330,26 @@ def _df008(instructions: list[tuple[int, str, str]], path: Path) -> list[Finding
|
|
|
330
330
|
if "--checksum=sha256:" in args:
|
|
331
331
|
continue
|
|
332
332
|
|
|
333
|
-
#
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
333
|
+
# Walk the full continuation chain — inspect every subsequent RUN
|
|
334
|
+
# instruction (each already collapsed across `\` continuations by
|
|
335
|
+
# the parser) until we hit a non-RUN instruction. This catches
|
|
336
|
+
# `RUN wget && sha256sum -c && tar xz` chains and multi-RUN
|
|
337
|
+
# verification sequences that overflowed the old 3-instruction
|
|
338
|
+
# window.
|
|
339
|
+
verified = False
|
|
340
|
+
for _, next_instr, next_args in instructions[idx + 1:]:
|
|
341
|
+
if next_instr != "RUN":
|
|
342
|
+
break
|
|
343
|
+
if (
|
|
337
344
|
"sha256sum" in next_args
|
|
345
|
+
or "sha512sum" in next_args
|
|
338
346
|
or "shasum -a 256" in next_args
|
|
347
|
+
or "shasum -a 512" in next_args
|
|
339
348
|
or "gpg --verify" in next_args
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
349
|
+
or "gpg2 --verify" in next_args
|
|
350
|
+
):
|
|
351
|
+
verified = True
|
|
352
|
+
break
|
|
343
353
|
if verified:
|
|
344
354
|
continue
|
|
345
355
|
|
|
@@ -359,18 +369,30 @@ def _df008(instructions: list[tuple[int, str, str]], path: Path) -> list[Finding
|
|
|
359
369
|
|
|
360
370
|
|
|
361
371
|
def _df009(instructions: list[tuple[int, str, str]], path: Path) -> list[Finding]:
|
|
362
|
-
"""PROOFCTL-DF-009 — COPY . . before dependency installation.
|
|
372
|
+
"""PROOFCTL-DF-009 — COPY . . before dependency installation.
|
|
373
|
+
|
|
374
|
+
Multi-stage context: in a Dockerfile with multiple FROM stages,
|
|
375
|
+
early/builder stages routinely COPY . to compile or vendor
|
|
376
|
+
dependencies; the cache-invalidation antipattern only matters in
|
|
377
|
+
the final (runtime) stage. Skip the rule entirely for non-final
|
|
378
|
+
stages.
|
|
379
|
+
"""
|
|
363
380
|
findings: list[Finding] = []
|
|
364
381
|
|
|
365
|
-
#
|
|
366
|
-
|
|
382
|
+
# Determine the line range of the FINAL build stage. Anything
|
|
383
|
+
# before that range belongs to an earlier (e.g. builder) stage.
|
|
384
|
+
from_linenos = [ln for ln, instr, _ in instructions if instr == "FROM"]
|
|
385
|
+
is_multistage = len(from_linenos) > 1
|
|
386
|
+
final_stage_start = from_linenos[-1] if from_linenos else 0
|
|
367
387
|
|
|
368
|
-
# Find the first broad COPY (source is bare ".")
|
|
388
|
+
# Find the first broad COPY (source is bare ".") in the FINAL stage.
|
|
369
389
|
first_broad_copy: tuple[int, str, str] | None = None
|
|
370
390
|
for item in instructions:
|
|
371
391
|
lineno, instr, args = item
|
|
372
392
|
if instr != "COPY":
|
|
373
393
|
continue
|
|
394
|
+
if is_multistage and lineno < final_stage_start:
|
|
395
|
+
continue # earlier stage — not subject to DF-009
|
|
374
396
|
tokens = args.split()
|
|
375
397
|
if not tokens:
|
|
376
398
|
continue
|
|
@@ -384,10 +406,14 @@ def _df009(instructions: list[tuple[int, str, str]], path: Path) -> list[Finding
|
|
|
384
406
|
|
|
385
407
|
copy_lineno = first_broad_copy[0]
|
|
386
408
|
|
|
387
|
-
# Find the first dependency-install RUN
|
|
409
|
+
# Find the first dependency-install RUN — only consider RUNs in
|
|
410
|
+
# the final stage (otherwise we'd cross-match a builder-stage RUN
|
|
411
|
+
# with a final-stage COPY).
|
|
388
412
|
for lineno, instr, args in instructions:
|
|
389
413
|
if instr != "RUN":
|
|
390
414
|
continue
|
|
415
|
+
if is_multistage and lineno < final_stage_start:
|
|
416
|
+
continue
|
|
391
417
|
if _DEP_INSTALL_RE.search(args):
|
|
392
418
|
if copy_lineno < lineno:
|
|
393
419
|
findings.append(Finding(
|
|
@@ -489,9 +489,13 @@ class _Q009Visitor(ast.NodeVisitor):
|
|
|
489
489
|
return False
|
|
490
490
|
left, right = test.left, test.comparators[0]
|
|
491
491
|
# Match either side: `__name__ == "__main__"` or `"__main__" == __name__`.
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
492
|
+
def _is_dunder(n: ast.expr) -> bool:
|
|
493
|
+
return isinstance(n, ast.Name) and n.id == "__name__"
|
|
494
|
+
|
|
495
|
+
def _is_main(n: ast.expr) -> bool:
|
|
496
|
+
return isinstance(n, ast.Constant) and n.value == "__main__"
|
|
497
|
+
|
|
498
|
+
return (_is_dunder(left) and _is_main(right)) or (_is_main(left) and _is_dunder(right))
|
|
495
499
|
|
|
496
500
|
def visit_If(self, node: ast.If) -> None:
|
|
497
501
|
if self._is_main_guard(node):
|
|
@@ -9,8 +9,12 @@ from .base import ShellFileChecker
|
|
|
9
9
|
|
|
10
10
|
# ── patterns ──────────────────────────────────────────────────────────────────
|
|
11
11
|
|
|
12
|
-
_SHEBANG_RE = re.compile(
|
|
13
|
-
|
|
12
|
+
_SHEBANG_RE = re.compile(
|
|
13
|
+
r"^#!\s*/(?:usr/(?:local/)?)?bin/(?:env\s+)?(?:bash|sh|zsh|ksh)\b(?:\s.*)?$"
|
|
14
|
+
)
|
|
15
|
+
_BASH_SHEBANG_RE = re.compile(
|
|
16
|
+
r"^#!\s*/(?:usr/(?:local/)?)?bin/(?:env\s+)?bash\b(?:\s.*)?$"
|
|
17
|
+
)
|
|
14
18
|
|
|
15
19
|
_SET_E_RE = re.compile(
|
|
16
20
|
r"(?:^|\s)set\s+(?:-[a-zA-Z]*e[a-zA-Z]*|-o\s+errexit)", re.MULTILINE
|
|
@@ -21,7 +25,24 @@ _PIPE_RE = re.compile(r"[^|]\|[^|]")
|
|
|
21
25
|
_CURL_BASH_RE = re.compile(
|
|
22
26
|
r"(?:curl|wget)\s+[^\n|]+\|\s*(?:sudo\s+)?(?:bash|sh|zsh)\b", re.MULTILINE
|
|
23
27
|
)
|
|
24
|
-
_RM_RF_VAR_RE = re.compile(
|
|
28
|
+
_RM_RF_VAR_RE = re.compile(
|
|
29
|
+
r"\brm\s+-rf\s+\"?/\$\{?(?P<var>[A-Za-z_][A-Za-z0-9_]*)\}?",
|
|
30
|
+
re.MULTILINE,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Detect a guarding non-empty test of $VAR on the same line or up to
|
|
34
|
+
# 5 lines above. Matches both [[ -n "$VAR" ]] and [ -n "$VAR" ] forms,
|
|
35
|
+
# with optional braces around the variable name.
|
|
36
|
+
def _has_guard(lines: list[str], line_idx: int, var: str) -> bool:
|
|
37
|
+
"""Return True if a non-empty guard for `var` precedes line_idx."""
|
|
38
|
+
pattern = re.compile(
|
|
39
|
+
r"\[\[?\s*-n\s+\"?\$\{?" + re.escape(var) + r"\}?\"?\s*\]\]?"
|
|
40
|
+
)
|
|
41
|
+
start = max(0, line_idx - 5)
|
|
42
|
+
for i in range(start, line_idx + 1):
|
|
43
|
+
if pattern.search(lines[i]):
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
25
46
|
|
|
26
47
|
# Placeholder variable assignments
|
|
27
48
|
_PLACEHOLDER_VAR_RE = re.compile(
|
|
@@ -152,7 +173,7 @@ def _sh004_curl_bash(path: Path, source: str, lines: list[str]) -> list[Finding]
|
|
|
152
173
|
path, lineno,
|
|
153
174
|
"PROOFCTL-SH-004", "Remote code execution via curl|bash",
|
|
154
175
|
Severity.ERROR,
|
|
155
|
-
|
|
176
|
+
"curl/wget piped directly to bash — executes unverified remote code",
|
|
156
177
|
hint="Download to a temp file, verify its SHA256 checksum, then execute.",
|
|
157
178
|
authority="Google Shell Style Guide / CWE-78",
|
|
158
179
|
docs_url="https://cwe.mitre.org/data/definitions/78.html",
|
|
@@ -161,10 +182,21 @@ def _sh004_curl_bash(path: Path, source: str, lines: list[str]) -> list[Finding]
|
|
|
161
182
|
|
|
162
183
|
|
|
163
184
|
def _sh005_rm_rf_var(path: Path, source: str, lines: list[str]) -> list[Finding]:
|
|
164
|
-
"""PROOFCTL-SH-005 — rm -rf /$VARIABLE (root wipe if var is empty).
|
|
185
|
+
"""PROOFCTL-SH-005 — rm -rf /$VARIABLE (root wipe if var is empty).
|
|
186
|
+
|
|
187
|
+
Skip the finding when a non-empty guard for the same variable
|
|
188
|
+
appears on the same logical line or within a few lines above —
|
|
189
|
+
e.g. ``[[ -n "$VAR" ]] && rm -rf "/$VAR"`` or an enclosing
|
|
190
|
+
``if [ -n "$VAR" ]; then rm -rf "/$VAR"; fi`` block.
|
|
191
|
+
"""
|
|
165
192
|
findings: list[Finding] = []
|
|
166
193
|
for m in _RM_RF_VAR_RE.finditer(source):
|
|
167
194
|
lineno = source[: m.start()].count("\n") + 1
|
|
195
|
+
var = m.group("var")
|
|
196
|
+
# 0-based index for `lines`
|
|
197
|
+
idx = lineno - 1
|
|
198
|
+
if 0 <= idx < len(lines) and _has_guard(lines, idx, var):
|
|
199
|
+
continue
|
|
168
200
|
findings.append(_f(
|
|
169
201
|
path, lineno,
|
|
170
202
|
"PROOFCTL-SH-005", "Dangerous rm -rf on variable path",
|
|
@@ -304,7 +304,7 @@ class TerraformChecker(HclFileChecker):
|
|
|
304
304
|
if b.kind != "resource":
|
|
305
305
|
continue
|
|
306
306
|
inner = b.raw_lines[1:-1] if len(b.raw_lines) > 2 else []
|
|
307
|
-
content = [
|
|
307
|
+
content = [line for line in inner if line.strip() and not line.strip().startswith("#")]
|
|
308
308
|
if not content:
|
|
309
309
|
out.append(self._f(
|
|
310
310
|
path, b.start_line,
|
|
@@ -394,7 +394,6 @@ class TerraformChecker(HclFileChecker):
|
|
|
394
394
|
def _t004(self, path: Path, blocks: list[HclBlock], lines: list[str]) -> list[Finding]:
|
|
395
395
|
"""T-004 — Open ingress on sensitive ports (0.0.0.0/0)."""
|
|
396
396
|
out = []
|
|
397
|
-
any_cidr = re.compile(r'(?:cidr_blocks|cidr_ranges|source_ranges)\s*=\s*\[.*"0\.0\.0\.0/0"')
|
|
398
397
|
port_re = re.compile(r'(?:from_port|to_port|port)\s*=\s*(\d+)')
|
|
399
398
|
all_traffic_re = re.compile(r'(?:protocol|ip_protocol)\s*=\s*"-1"|all\s*=\s*true')
|
|
400
399
|
|
|
@@ -653,9 +652,6 @@ class TerraformChecker(HclFileChecker):
|
|
|
653
652
|
def _v001(self, path: Path, blocks: list[HclBlock]) -> list[Finding]:
|
|
654
653
|
"""V-001 — force_destroy / deletion_protection=false on prod-named resource."""
|
|
655
654
|
out = []
|
|
656
|
-
dangerous = re.compile(
|
|
657
|
-
r'(?:force_destroy\s*=\s*true|deletion_protection\s*=\s*false)', re.IGNORECASE
|
|
658
|
-
)
|
|
659
655
|
for b in blocks:
|
|
660
656
|
if b.kind != "resource":
|
|
661
657
|
continue
|
|
@@ -2001,8 +1997,15 @@ class TerraformChecker(HclFileChecker):
|
|
|
2001
1997
|
|
|
2002
1998
|
def _t015(self, path: Path, blocks: list[HclBlock]) -> list[Finding]:
|
|
2003
1999
|
"""T-015 — Output with sensitive-looking name but sensitive = true not set."""
|
|
2000
|
+
# "key" is excluded when followed by measurement/metadata suffixes
|
|
2001
|
+
# (e.g. max_key_age_days, key_count, key_rotation_period) — those are
|
|
2002
|
+
# not sensitive values, just numeric descriptors that happen to contain
|
|
2003
|
+
# the word "key". Other terms (password, secret, token, credential)
|
|
2004
|
+
# are always sensitive regardless of suffix.
|
|
2004
2005
|
_sensitive_name_pat = re.compile(
|
|
2005
|
-
r'(?:password|secret|token|
|
|
2006
|
+
r'(?:password|secret|token|credential'
|
|
2007
|
+
r'|key(?!_age|_days|_hours|_seconds|_count|_max|_min|_num'
|
|
2008
|
+
r'|_threshold|_expir|_rotation|_version|_size|_length))',
|
|
2006
2009
|
re.IGNORECASE,
|
|
2007
2010
|
)
|
|
2008
2011
|
out = []
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import re
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import
|
|
6
|
+
from typing import Iterator
|
|
7
7
|
|
|
8
8
|
import yaml
|
|
9
9
|
|
|
@@ -1400,67 +1400,76 @@ class _YamlCheckerImpl(YamlFileChecker):
|
|
|
1400
1400
|
def check(self, path: Path, source: str) -> list[Finding]:
|
|
1401
1401
|
findings: list[Finding] = []
|
|
1402
1402
|
|
|
1403
|
-
# ── duplicate key detection
|
|
1404
|
-
loader
|
|
1405
|
-
|
|
1403
|
+
# ── duplicate key detection (multi-doc aware) ─────────────────────────
|
|
1404
|
+
# Use a fresh loader per document so _dup_findings does not leak
|
|
1405
|
+
# from doc N into doc N+1 in a `---` separated stream.
|
|
1406
|
+
all_dup_findings: list[tuple[str, int]] = []
|
|
1406
1407
|
try:
|
|
1407
|
-
loader
|
|
1408
|
+
loader = _DupKeyLoader(source)
|
|
1409
|
+
loader._dup_findings = [] # type: ignore[attr-defined]
|
|
1410
|
+
try:
|
|
1411
|
+
while loader.check_data():
|
|
1412
|
+
loader.get_data()
|
|
1413
|
+
all_dup_findings.extend(loader._dup_findings) # type: ignore[attr-defined]
|
|
1414
|
+
# Reset between documents — findings already harvested.
|
|
1415
|
+
loader._dup_findings = [] # type: ignore[attr-defined]
|
|
1416
|
+
finally:
|
|
1417
|
+
loader.dispose()
|
|
1408
1418
|
except yaml.YAMLError:
|
|
1409
1419
|
pass
|
|
1410
|
-
|
|
1411
|
-
loader.dispose()
|
|
1412
|
-
findings.extend(_yaml002_findings(loader._dup_findings, path)) # type: ignore[attr-defined]
|
|
1420
|
+
findings.extend(_yaml002_findings(all_dup_findings, path))
|
|
1413
1421
|
|
|
1414
|
-
# ── node-based checks
|
|
1422
|
+
# ── node-based checks (multi-doc aware) ───────────────────────────────
|
|
1415
1423
|
try:
|
|
1416
|
-
|
|
1424
|
+
roots = list(yaml.compose_all(source, Loader=yaml.SafeLoader))
|
|
1417
1425
|
except yaml.YAMLError:
|
|
1418
1426
|
return findings # unparseable — dup findings are still valid
|
|
1419
1427
|
|
|
1420
|
-
|
|
1421
|
-
|
|
1428
|
+
for root in roots:
|
|
1429
|
+
if root is None:
|
|
1430
|
+
continue
|
|
1422
1431
|
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1432
|
+
findings.extend(_yaml001(root, path))
|
|
1433
|
+
|
|
1434
|
+
if _is_k8s(root):
|
|
1435
|
+
assert isinstance(root, yaml.MappingNode)
|
|
1436
|
+
findings.extend(_yaml003_k8s(root, path))
|
|
1437
|
+
findings.extend(_yaml004_k8s(root, path))
|
|
1438
|
+
findings.extend(_yaml005_k8s(root, path))
|
|
1439
|
+
findings.extend(_yaml006_image_latest(root, path, "PROOFCTL-YAML-006"))
|
|
1440
|
+
findings.extend(_yaml009_k8s(root, path))
|
|
1441
|
+
findings.extend(_yaml010_k8s(root, path))
|
|
1442
|
+
findings.extend(_yaml011_k8s(root, path))
|
|
1443
|
+
findings.extend(_yaml012_k8s(root, path))
|
|
1444
|
+
findings.extend(_yaml013_k8s(root, path))
|
|
1445
|
+
findings.extend(_yaml014_k8s(root, path))
|
|
1446
|
+
findings.extend(_yaml015_k8s(root, path))
|
|
1447
|
+
findings.extend(_yaml016_017_k8s(root, path))
|
|
1448
|
+
findings.extend(_yaml018_k8s(root, path))
|
|
1449
|
+
findings.extend(_yaml020_k8s(root, path))
|
|
1450
|
+
findings.extend(_yaml021_k8s(root, path))
|
|
1451
|
+
# AI-slop: removed apiVersions, PSP, explicit root, deprecated annotations
|
|
1452
|
+
findings.extend(_yaml_ai001_k8s_removed_api(root, path))
|
|
1453
|
+
findings.extend(_yaml_ai002_pod_security_policy(root, path))
|
|
1454
|
+
findings.extend(_yaml_ai003_run_as_user_root(root, path))
|
|
1455
|
+
findings.extend(_yaml_ai006_ingress_class_annotation(root, path))
|
|
1456
|
+
|
|
1457
|
+
elif _is_compose(root):
|
|
1458
|
+
findings.extend(_yaml006_image_latest(root, path, "PROOFCTL-YAML-006"))
|
|
1459
|
+
findings.extend(_yaml008_compose_privileged(root, path))
|
|
1460
|
+
|
|
1461
|
+
if _is_gha(path) or _is_gha_content(root):
|
|
1462
|
+
findings.extend(_yaml007_gha(root, path))
|
|
1463
|
+
if isinstance(root, yaml.MappingNode):
|
|
1464
|
+
findings.extend(_yaml_gha001(root, path))
|
|
1465
|
+
findings.extend(_yaml_gha002(root, path))
|
|
1466
|
+
findings.extend(_yaml_gha003(root, path))
|
|
1467
|
+
findings.extend(_yaml_gha004(root, path))
|
|
1468
|
+
findings.extend(_yaml_gha005(root, path))
|
|
1469
|
+
findings.extend(_yaml_gha006(root, path))
|
|
1470
|
+
# AI-slop: deprecated workflow commands, stale action versions
|
|
1471
|
+
findings.extend(_yaml_ai004_gha_deprecated_commands(root, path))
|
|
1472
|
+
findings.extend(_yaml_ai005_old_action_versions(root, path))
|
|
1464
1473
|
|
|
1465
1474
|
return findings
|
|
1466
1475
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import sys
|
|
3
4
|
from enum import Enum
|
|
4
5
|
from pathlib import Path
|
|
5
|
-
from typing import
|
|
6
|
+
from typing import Optional
|
|
6
7
|
|
|
7
8
|
import typer
|
|
8
9
|
|
|
@@ -10,6 +11,29 @@ from .config import load_config
|
|
|
10
11
|
from .engine import run_check
|
|
11
12
|
from .registry import RULES, families_help_string, is_hidden_family
|
|
12
13
|
|
|
14
|
+
|
|
15
|
+
def _ensure_utf8_io() -> None:
|
|
16
|
+
"""Force stdout/stderr to UTF-8.
|
|
17
|
+
|
|
18
|
+
Rich-rendered tables embed box-drawing characters (─ │ ╭),
|
|
19
|
+
arrows (→), and emoji (📖 ✓) that can't be encoded under
|
|
20
|
+
Windows' default cp1252 locale — proofctl crashes mid-render with
|
|
21
|
+
UnicodeEncodeError. Reconfiguring at startup makes output portable
|
|
22
|
+
regardless of host locale; `errors="replace"` is a defensive fallback
|
|
23
|
+
so a misconfigured stream never aborts the run.
|
|
24
|
+
"""
|
|
25
|
+
for stream_name in ("stdout", "stderr"):
|
|
26
|
+
stream = getattr(sys, stream_name, None)
|
|
27
|
+
if stream is None or not hasattr(stream, "reconfigure"):
|
|
28
|
+
continue
|
|
29
|
+
try:
|
|
30
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
31
|
+
except (OSError, ValueError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_ensure_utf8_io()
|
|
36
|
+
|
|
13
37
|
app = typer.Typer(
|
|
14
38
|
name="proofctl",
|
|
15
39
|
help="Detects AI-generated slop: hallucinated imports, phantom methods, placeholder code, cross-language leakage, and variant file debris.",
|
|
@@ -9,16 +9,72 @@ import yaml
|
|
|
9
9
|
from .models import Severity
|
|
10
10
|
|
|
11
11
|
_DEFAULT_EXCLUDES: list[str] = [
|
|
12
|
+
# ── Python project environments ───────────────────────────────────────────
|
|
12
13
|
"**/.venv/**",
|
|
13
14
|
"**/venv/**",
|
|
14
|
-
"**/node_modules/**",
|
|
15
|
-
"**/.git/**",
|
|
16
15
|
"**/__pycache__/**",
|
|
17
16
|
"**/*.egg-info/**",
|
|
18
17
|
"**/dist/**",
|
|
19
18
|
"**/build/**",
|
|
20
|
-
|
|
19
|
+
# ── Universal VCS / package managers ─────────────────────────────────────
|
|
20
|
+
"**/.git/**",
|
|
21
|
+
"**/node_modules/**",
|
|
22
|
+
# ── Terraform / Terragrunt caches ─────────────────────────────────────────
|
|
21
23
|
"**/.terraform/**",
|
|
24
|
+
"**/.terragrunt-cache/**",
|
|
25
|
+
# Terraform/OpenTofu state and plan artifacts — never hand-authored;
|
|
26
|
+
# may contain sensitive resource outputs and secrets.
|
|
27
|
+
"**/*.tfstate",
|
|
28
|
+
"**/*.tfstate.backup",
|
|
29
|
+
"**/*.tfplan",
|
|
30
|
+
# ── Flux CD ───────────────────────────────────────────────────────────────
|
|
31
|
+
# Both files are auto-generated by `flux bootstrap` and contain the comment
|
|
32
|
+
# "# This manifest was generated by flux. DO NOT EDIT."
|
|
33
|
+
# gotk-components.yaml carries wildcard RBAC + cluster-admin bindings that
|
|
34
|
+
# are intentional Flux design; gotk-sync.yaml wires the GitRepository/
|
|
35
|
+
# Kustomization that reconciles flux-system itself. Neither is user policy.
|
|
36
|
+
"**/flux-system/gotk-components.yaml",
|
|
37
|
+
"**/flux-system/gotk-sync.yaml",
|
|
38
|
+
# ── ArgoCD Autopilot ──────────────────────────────────────────────────────
|
|
39
|
+
# Files generated by `argocd-autopilot repo bootstrap`. The kustomization
|
|
40
|
+
# references upstream install.yaml which carries a cluster-admin binding for
|
|
41
|
+
# argocd-application-controller — intentional, not a misconfiguration.
|
|
42
|
+
"**/bootstrap/argo-cd/kustomization.yaml",
|
|
43
|
+
"**/bootstrap/argo-cd.yaml",
|
|
44
|
+
"**/bootstrap/cluster-resources/in-cluster/**",
|
|
45
|
+
# ── AWS CDK / CDK for Terraform ───────────────────────────────────────────
|
|
46
|
+
# cdk.out/ is synthesized CloudFormation JSON from `cdk synth`.
|
|
47
|
+
# .gen/ and cdktf.out/ are CDKTF provider bindings and synth output.
|
|
48
|
+
"**/cdk.out/**",
|
|
49
|
+
"**/.gen/**",
|
|
50
|
+
"**/cdktf.out/**",
|
|
51
|
+
# ── Helm ─────────────────────────────────────────────────────────────────
|
|
52
|
+
# Chart tarballs downloaded by `helm dep update` into charts/ subdirectories.
|
|
53
|
+
"**/charts/*.tgz",
|
|
54
|
+
# ── Pulumi ───────────────────────────────────────────────────────────────
|
|
55
|
+
# Local backend state written by `pulumi up` when using file:// backend.
|
|
56
|
+
"**/.pulumi/**",
|
|
57
|
+
# ── Other IaC tool caches ─────────────────────────────────────────────────
|
|
58
|
+
"**/packer_cache/**", # Packer: ISO/VM template cache (default ./packer_cache/)
|
|
59
|
+
"**/.vagrant/**", # Vagrant: machine state, private keys, VM references
|
|
60
|
+
"**/.crossplane/**", # Crossplane CLI: provider OCI layer / CRD schema cache
|
|
61
|
+
# ── Ansible Galaxy ────────────────────────────────────────────────────────
|
|
62
|
+
# Collections installed via `ansible-galaxy collection install`.
|
|
63
|
+
"**/collections/ansible_collections/**",
|
|
64
|
+
# ── Go vendor ─────────────────────────────────────────────────────────────
|
|
65
|
+
# Third-party module source vendored by `go mod vendor`. Contains .go files
|
|
66
|
+
# that can trigger false-positives in import / quality rules.
|
|
67
|
+
"**/vendor/**",
|
|
68
|
+
# ── Python tool caches ────────────────────────────────────────────────────
|
|
69
|
+
# These directories contain .py files and can surface false-positives if
|
|
70
|
+
# scanned (e.g. generated test helpers, mypy stubs, hypothesis examples).
|
|
71
|
+
"**/.tox/**",
|
|
72
|
+
"**/.nox/**",
|
|
73
|
+
"**/.pytest_cache/**",
|
|
74
|
+
"**/.mypy_cache/**",
|
|
75
|
+
"**/.ruff_cache/**",
|
|
76
|
+
"**/.hypothesis/**",
|
|
77
|
+
"**/htmlcov/**",
|
|
22
78
|
]
|
|
23
79
|
|
|
24
80
|
# Top-level config keys recognised by `.proofctl.yaml`. Anything else triggers
|
|
@@ -17,7 +17,6 @@ import ast
|
|
|
17
17
|
import dataclasses
|
|
18
18
|
import re
|
|
19
19
|
import subprocess
|
|
20
|
-
import traceback
|
|
21
20
|
from pathlib import Path
|
|
22
21
|
from typing import Iterable
|
|
23
22
|
|
|
@@ -412,7 +411,24 @@ def run_check(
|
|
|
412
411
|
floor = Severity.from_str(min_severity)
|
|
413
412
|
findings = [f for f in findings if f.severity >= floor]
|
|
414
413
|
|
|
415
|
-
|
|
414
|
+
sorted_findings = sorted(findings, key=lambda x: (-x.severity, x.file, x.line or 0))
|
|
415
|
+
|
|
416
|
+
# ── 8. Module-level rule deduplication. ───────────────────────────────────
|
|
417
|
+
# Rules like T014 (missing required_version) represent a per-module property,
|
|
418
|
+
# not a per-file one. A module with 10 .tf files should produce one finding,
|
|
419
|
+
# not ten. After sorting we keep the first occurrence per (dir, rule_id) so
|
|
420
|
+
# the preserved finding is always the alphabetically-first file in the module.
|
|
421
|
+
_MODULE_LEVEL_RULES: frozenset[str] = frozenset({"PROOFCTL-TF-T014"})
|
|
422
|
+
seen_module_dirs: set[tuple[str, str]] = set()
|
|
423
|
+
result: list[Finding] = []
|
|
424
|
+
for f in sorted_findings:
|
|
425
|
+
if f.rule_id in _MODULE_LEVEL_RULES:
|
|
426
|
+
dir_key = (str(Path(f.file).parent), f.rule_id)
|
|
427
|
+
if dir_key in seen_module_dirs:
|
|
428
|
+
continue
|
|
429
|
+
seen_module_dirs.add(dir_key)
|
|
430
|
+
result.append(f)
|
|
431
|
+
return result
|
|
416
432
|
|
|
417
433
|
|
|
418
434
|
def _normalise(f: Finding, root: Path, config: ProofctlConfig) -> Finding | None:
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import html
|
|
4
|
-
import json
|
|
5
4
|
from collections import Counter
|
|
6
5
|
from datetime import datetime, timezone
|
|
7
6
|
|
|
@@ -160,6 +159,7 @@ class HtmlReporter:
|
|
|
160
159
|
<head>
|
|
161
160
|
<meta charset="UTF-8">
|
|
162
161
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
162
|
+
<meta name="proofctl:schema_version" content="1">
|
|
163
163
|
<title>proofctl report</title>
|
|
164
164
|
<style>{_CSS}</style>
|
|
165
165
|
</head>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections import Counter
|
|
5
|
+
|
|
6
|
+
from ..models import Finding, Severity
|
|
7
|
+
|
|
8
|
+
# Bumped in lockstep with documented JSON schema changes. Consumers can branch
|
|
9
|
+
# on this field. Start at 1 since this is the first formally-stable schema.
|
|
10
|
+
SCHEMA_VERSION = 1
|
|
11
|
+
|
|
12
|
+
# Stable field order for each finding object. Matches the order documented in
|
|
13
|
+
# the README and used by external tooling.
|
|
14
|
+
_FINDING_FIELDS = (
|
|
15
|
+
"file",
|
|
16
|
+
"line",
|
|
17
|
+
"col",
|
|
18
|
+
"rule_id",
|
|
19
|
+
"rule_name",
|
|
20
|
+
"severity",
|
|
21
|
+
"message",
|
|
22
|
+
"hint",
|
|
23
|
+
"authority",
|
|
24
|
+
"docs_url",
|
|
25
|
+
"fixable",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JsonReporter:
|
|
30
|
+
def render(self, findings: list[Finding]) -> str:
|
|
31
|
+
counts = Counter(f.severity for f in findings)
|
|
32
|
+
|
|
33
|
+
# Stable summary order: total, ERROR, WARNING, INFO (matches README).
|
|
34
|
+
summary = {
|
|
35
|
+
"total": len(findings),
|
|
36
|
+
"ERROR": counts[Severity.ERROR],
|
|
37
|
+
"WARNING": counts[Severity.WARNING],
|
|
38
|
+
"INFO": counts[Severity.INFO],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
serialisable = []
|
|
42
|
+
for f in findings:
|
|
43
|
+
d = {
|
|
44
|
+
"file": f.file,
|
|
45
|
+
"line": f.line,
|
|
46
|
+
"col": f.col,
|
|
47
|
+
"rule_id": f.rule_id,
|
|
48
|
+
"rule_name": f.rule_name,
|
|
49
|
+
"severity": f.severity.label(),
|
|
50
|
+
"message": f.message,
|
|
51
|
+
"hint": f.hint,
|
|
52
|
+
"authority": f.authority,
|
|
53
|
+
"docs_url": f.docs_url,
|
|
54
|
+
"fixable": f.fixable,
|
|
55
|
+
}
|
|
56
|
+
serialisable.append(d)
|
|
57
|
+
|
|
58
|
+
payload = {
|
|
59
|
+
"schema_version": SCHEMA_VERSION,
|
|
60
|
+
"summary": summary,
|
|
61
|
+
"findings": serialisable,
|
|
62
|
+
}
|
|
63
|
+
return json.dumps(payload, indent=2)
|