proofctl 0.1.11__tar.gz → 0.2.0__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.0}/PKG-INFO +1 -1
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/checkers/dockerfile.py +39 -13
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/checkers/placeholders.py +0 -2
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/checkers/quality.py +7 -3
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/checkers/shell_checker.py +37 -5
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/checkers/terraform.py +1 -5
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/checkers/yaml_checker.py +62 -53
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/cli.py +25 -1
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/engine.py +0 -1
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/models.py +1 -1
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/reporters/html_reporter.py +1 -1
- proofctl-0.2.0/proofctl/reporters/json_reporter.py +63 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/reporters/terminal.py +17 -4
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl.egg-info/PKG-INFO +1 -1
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl.egg-info/SOURCES.txt +2 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/pyproject.toml +1 -1
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_dockerfile.py +6 -3
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_phase1_audit_fixes.py +6 -2
- proofctl-0.2.0/tests/test_phase6_audit_fixes.py +359 -0
- proofctl-0.2.0/tests/test_phase8_audit_fixes.py +328 -0
- proofctl-0.1.11/proofctl/reporters/json_reporter.py +0 -22
- {proofctl-0.1.11 → proofctl-0.2.0}/LICENSE +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/README.md +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/__init__.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/_globmatch.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/baseline.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/checkers/__init__.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/checkers/base.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/checkers/hcl_utils.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/checkers/imports.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/checkers/leakage.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/checkers/llm_integration.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/checkers/security.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/checkers/terragrunt.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/checkers/variants.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/config.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/fixer.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/registry.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl/reporters/__init__.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl.egg-info/dependency_links.txt +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl.egg-info/entry_points.txt +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl.egg-info/requires.txt +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/proofctl.egg-info/top_level.txt +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/setup.cfg +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_baseline.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_config_proofctl.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_engine.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_fixer.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_imports.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_leakage.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_llm.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_local_packages.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_phase2_audit_fixes.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_phase3_audit_fixes.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_phase4_audit_fixes.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_phase5_llm_imports_fixes.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_phase5_p_l_v_fixes.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_phase5_quality_fixes.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_placeholders.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_quality.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_quality_ext.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_security.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_security_ext.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_suppression.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_terraform.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_terraform_ext.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_terragrunt.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_variants.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/tests/test_yaml.py +0 -0
- {proofctl-0.1.11 → proofctl-0.2.0}/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.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
@@ -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.",
|
|
@@ -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)
|
|
@@ -24,6 +24,10 @@ _SEV_STYLE: dict[Severity, str] = {
|
|
|
24
24
|
Severity.INFO: "dim",
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
# Default stdout console used when no console is injected. Retained as a
|
|
28
|
+
# module-level default for backward compatibility, but engine/CLI code should
|
|
29
|
+
# pass a Console explicitly to keep ordering deterministic in piped scenarios
|
|
30
|
+
# (see AUDIT 1.6 — Reporters: console unification).
|
|
27
31
|
console = Console(highlight=False)
|
|
28
32
|
|
|
29
33
|
|
|
@@ -34,19 +38,28 @@ def _location(f: Finding) -> str:
|
|
|
34
38
|
|
|
35
39
|
|
|
36
40
|
class TerminalReporter:
|
|
37
|
-
def render(
|
|
41
|
+
def render(
|
|
42
|
+
self,
|
|
43
|
+
findings: list[Finding],
|
|
44
|
+
output: Path | None = None,
|
|
45
|
+
console_: Console | None = None,
|
|
46
|
+
) -> str:
|
|
38
47
|
"""Render findings to the terminal, or capture to a string when `output` is set.
|
|
39
48
|
|
|
40
49
|
When `output` is provided, the reporter renders into an in-memory Console
|
|
41
50
|
and returns the captured text (with ANSI styling stripped) so the caller
|
|
42
|
-
can write it to disk. Otherwise, prints
|
|
43
|
-
and returns "".
|
|
51
|
+
can write it to disk. Otherwise, prints via the supplied `console_`
|
|
52
|
+
(or the module-level default) and returns "".
|
|
53
|
+
|
|
54
|
+
Passing an explicit `console_` lets callers share a single Console with
|
|
55
|
+
the engine so stdout/stderr ordering stays deterministic when output is
|
|
56
|
+
piped — the audit identified non-deterministic ordering as a defect.
|
|
44
57
|
"""
|
|
45
58
|
if output is not None:
|
|
46
59
|
buf = io.StringIO()
|
|
47
60
|
target = Console(file=buf, force_terminal=False, no_color=True, width=120, highlight=False)
|
|
48
61
|
else:
|
|
49
|
-
target = console
|
|
62
|
+
target = console_ if console_ is not None else console
|
|
50
63
|
|
|
51
64
|
if not findings:
|
|
52
65
|
target.print(Padding("[bold green]✓ No findings.[/bold green]", (1, 2)))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proofctl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
@@ -51,6 +51,8 @@ tests/test_phase4_audit_fixes.py
|
|
|
51
51
|
tests/test_phase5_llm_imports_fixes.py
|
|
52
52
|
tests/test_phase5_p_l_v_fixes.py
|
|
53
53
|
tests/test_phase5_quality_fixes.py
|
|
54
|
+
tests/test_phase6_audit_fixes.py
|
|
55
|
+
tests/test_phase8_audit_fixes.py
|
|
54
56
|
tests/test_placeholders.py
|
|
55
57
|
tests/test_quality.py
|
|
56
58
|
tests/test_quality_ext.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proofctl"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Zero-dependency linter for Python, Terraform, Dockerfiles, Kubernetes, and GitHub Actions — catches AI slop and security misconfigurations pre-commit"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -326,7 +326,11 @@ def test_df008_add_local_file_not_flagged():
|
|
|
326
326
|
|
|
327
327
|
|
|
328
328
|
def test_df008_verify_outside_window_flags():
|
|
329
|
-
"""
|
|
329
|
+
"""Phase 6 fix: walking the full RUN continuation chain (until a
|
|
330
|
+
non-RUN instruction) means sha256sum verification any number of
|
|
331
|
+
RUN steps after the ADD now suppresses the finding. The OLD
|
|
332
|
+
behaviour capped the lookahead at 3 instructions and therefore
|
|
333
|
+
flagged this — that was the bug fixed in Phase 6 of the audit."""
|
|
330
334
|
src = (
|
|
331
335
|
"FROM ubuntu:22.04\n"
|
|
332
336
|
"ADD https://example.com/tool.tar.gz /tmp/tool.tar.gz\n"
|
|
@@ -336,8 +340,7 @@ def test_df008_verify_outside_window_flags():
|
|
|
336
340
|
"RUN sha256sum -c /tmp/tool.tar.gz.sha256\n"
|
|
337
341
|
"USER app\nCMD bash\n"
|
|
338
342
|
)
|
|
339
|
-
|
|
340
|
-
assert len(f) == 1
|
|
343
|
+
assert "PROOFCTL-DF-008" not in _ids(_check(src))
|
|
341
344
|
|
|
342
345
|
|
|
343
346
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
@@ -109,9 +109,13 @@ def test_c6_terminal_reporter_renders_to_string_when_output_set(tmp_path):
|
|
|
109
109
|
reporter = TerminalReporter()
|
|
110
110
|
content = reporter.render(findings, output=out_file)
|
|
111
111
|
assert content, "render() with output= must return non-empty captured text"
|
|
112
|
-
|
|
112
|
+
# Rich output contains box-drawing chars (│ ─), the 📖 emoji, and ✓ —
|
|
113
|
+
# Path.write_text defaults to the platform locale (cp1252 on Windows
|
|
114
|
+
# CI), which can't encode them. Force utf-8 to mirror what the CLI
|
|
115
|
+
# itself does in proofctl/cli.py.
|
|
116
|
+
out_file.write_text(content, encoding="utf-8")
|
|
113
117
|
assert out_file.stat().st_size > 0
|
|
114
|
-
assert "PROOFCTL-Q-001" in out_file.read_text()
|
|
118
|
+
assert "PROOFCTL-Q-001" in out_file.read_text(encoding="utf-8")
|
|
115
119
|
|
|
116
120
|
|
|
117
121
|
def test_c6_terminal_reporter_returns_empty_string_for_no_findings_with_output(tmp_path):
|