proofctl 0.1.10__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.10 → proofctl-0.2.0}/PKG-INFO +1 -1
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/dockerfile.py +39 -13
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/imports.py +73 -19
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/placeholders.py +0 -2
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/quality.py +7 -3
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/shell_checker.py +37 -5
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/terraform.py +1 -5
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/yaml_checker.py +62 -53
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/cli.py +25 -1
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/engine.py +7 -3
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/models.py +1 -1
- {proofctl-0.1.10 → 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.10 → proofctl-0.2.0}/proofctl/reporters/terminal.py +17 -4
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl.egg-info/PKG-INFO +1 -1
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl.egg-info/SOURCES.txt +2 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/pyproject.toml +1 -1
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_dockerfile.py +6 -3
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_local_packages.py +64 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_phase1_audit_fixes.py +6 -2
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_phase2_audit_fixes.py +2 -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.10/proofctl/reporters/json_reporter.py +0 -22
- {proofctl-0.1.10 → proofctl-0.2.0}/LICENSE +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/README.md +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/__init__.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/_globmatch.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/baseline.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/__init__.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/base.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/hcl_utils.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/leakage.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/llm_integration.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/security.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/terragrunt.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/checkers/variants.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/config.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/fixer.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/registry.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl/reporters/__init__.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl.egg-info/dependency_links.txt +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl.egg-info/entry_points.txt +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl.egg-info/requires.txt +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/proofctl.egg-info/top_level.txt +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/setup.cfg +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_baseline.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_config_proofctl.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_engine.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_fixer.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_imports.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_leakage.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_llm.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_phase3_audit_fixes.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_phase4_audit_fixes.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_phase5_llm_imports_fixes.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_phase5_p_l_v_fixes.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_phase5_quality_fixes.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_placeholders.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_quality.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_quality_ext.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_security.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_security_ext.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_suppression.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_terraform.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_terraform_ext.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_terragrunt.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_variants.py +0 -0
- {proofctl-0.1.10 → proofctl-0.2.0}/tests/test_yaml.py +0 -0
- {proofctl-0.1.10 → 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(
|
|
@@ -481,26 +481,65 @@ _PKG_SCAN_SKIP_DIRS: frozenset[str] = frozenset({
|
|
|
481
481
|
# is depth 3).
|
|
482
482
|
_PKG_SCAN_MAX_DEPTH = 8
|
|
483
483
|
|
|
484
|
+
# Files / directories that mark a project root for `_i001` local-package
|
|
485
|
+
# discovery. Kept broad on purpose: a monorepo with Airflow DAGs frequently has
|
|
486
|
+
# none of pyproject.toml / setup.py at the repo root, but always has `.git/`.
|
|
487
|
+
_PROJECT_ROOT_MARKERS: tuple[str, ...] = (
|
|
488
|
+
"pyproject.toml", "setup.py", "setup.cfg",
|
|
489
|
+
".git",
|
|
490
|
+
"Pipfile", "Pipfile.lock",
|
|
491
|
+
"poetry.lock", "uv.lock",
|
|
492
|
+
"requirements.txt", "requirements-dev.txt",
|
|
493
|
+
"tox.ini",
|
|
494
|
+
"environment.yml",
|
|
495
|
+
".proofctl.yaml", # the user's own marker
|
|
496
|
+
)
|
|
497
|
+
|
|
484
498
|
|
|
485
499
|
@functools.lru_cache(maxsize=32)
|
|
486
500
|
def _local_packages(root: Path) -> frozenset[str]:
|
|
487
501
|
"""Importable Python package/module names anywhere in the project.
|
|
488
502
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
503
|
+
A directory under `root` is treated as a local package when **all** of:
|
|
504
|
+
- its name is a valid Python identifier (filters out `schema-migrations`
|
|
505
|
+
and similar that can never appear as an import),
|
|
506
|
+
- it is not in the vendor / cache / virtualenv skip set,
|
|
507
|
+
- it either contains an `__init__.py` (regular package) **or** at least
|
|
508
|
+
one direct `.py` file (PEP 420 namespace package — common in Airflow
|
|
509
|
+
projects where `dags_folder` is on `sys.path` and DAG sub-directories
|
|
510
|
+
are imported without `__init__.py`).
|
|
492
511
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
spurious PROOFCTL-I-001 hallucinated-import findings against private code.
|
|
512
|
+
Bare top-level `.py` files at the project root are also recorded as
|
|
513
|
+
importable modules.
|
|
496
514
|
|
|
497
515
|
The walk skips common vendor / cache / virtualenv directories, never
|
|
498
516
|
follows symlinks, and caps recursion at `_PKG_SCAN_MAX_DEPTH`.
|
|
499
517
|
Results are cached per-root so a 500-file scan walks the tree once.
|
|
518
|
+
|
|
519
|
+
Why we add namespace packages even without an `__init__.py`:
|
|
520
|
+
Airflow's standard layout has `<dags_folder>/<pkg>/<module>.py` with
|
|
521
|
+
`<dags_folder>` on `sys.path`; `from <pkg> import …` resolves at runtime
|
|
522
|
+
even though `<pkg>` is just a plain directory. Without this branch every
|
|
523
|
+
such directory triggered a false PROOFCTL-I-001.
|
|
524
|
+
|
|
525
|
+
Trade-off: a directory whose name matches a real PyPI package will now
|
|
526
|
+
shadow that package in the hallucination check (false-negative rather
|
|
527
|
+
than false-positive). We accept that — the previous noise made the rule
|
|
528
|
+
unusable on namespace-package layouts, and the identifier + py-file
|
|
529
|
+
filters keep the false-negative surface narrow.
|
|
500
530
|
"""
|
|
501
531
|
names: set[str] = set()
|
|
502
532
|
visited: set[Path] = set()
|
|
503
533
|
|
|
534
|
+
def _has_direct_py_file(p: Path) -> bool:
|
|
535
|
+
try:
|
|
536
|
+
for c in p.iterdir():
|
|
537
|
+
if c.is_file() and not c.is_symlink() and c.suffix == ".py":
|
|
538
|
+
return True
|
|
539
|
+
except (OSError, PermissionError):
|
|
540
|
+
return False
|
|
541
|
+
return False
|
|
542
|
+
|
|
504
543
|
def _walk(path: Path, depth: int) -> None:
|
|
505
544
|
if depth > _PKG_SCAN_MAX_DEPTH:
|
|
506
545
|
return
|
|
@@ -526,8 +565,9 @@ def _local_packages(root: Path) -> frozenset[str]:
|
|
|
526
565
|
if child.name.startswith(".") and child.name not in {".github"}:
|
|
527
566
|
# Skip hidden directories — they're caches, IDE state, etc.
|
|
528
567
|
continue
|
|
529
|
-
if
|
|
530
|
-
|
|
568
|
+
if child.name.isidentifier():
|
|
569
|
+
if (child / "__init__.py").exists() or _has_direct_py_file(child):
|
|
570
|
+
names.add(child.name)
|
|
531
571
|
_walk(child, depth + 1)
|
|
532
572
|
elif depth == 0 and child.suffix == ".py" and child.name != "__init__.py":
|
|
533
573
|
# Bare top-level modules (e.g. `setup.py`-adjacent scripts).
|
|
@@ -558,10 +598,14 @@ class ImportChecker(FileChecker):
|
|
|
558
598
|
local_namespaces: list[str] | None = None,
|
|
559
599
|
extra_indexes: list[str] | None = None,
|
|
560
600
|
check_pypi: bool = True,
|
|
601
|
+
scan_root: Path | None = None,
|
|
561
602
|
) -> None:
|
|
562
603
|
self._local_namespaces = tuple(local_namespaces or [])
|
|
563
604
|
self._check_pypi = check_pypi
|
|
564
605
|
self._stdlib = _stdlib_names()
|
|
606
|
+
# Engine plumbs its own scan root in; falls back to walking-up
|
|
607
|
+
# heuristic when used standalone (e.g. unit tests).
|
|
608
|
+
self._scan_root = scan_root
|
|
565
609
|
|
|
566
610
|
def check(self, path: Path, source: str, tree: ast.Module | None) -> list[Finding]:
|
|
567
611
|
findings: list[Finding] = []
|
|
@@ -570,17 +614,27 @@ class ImportChecker(FileChecker):
|
|
|
570
614
|
return findings
|
|
571
615
|
|
|
572
616
|
def _i001(self, path: Path, tree: ast.Module) -> list[Finding]:
|
|
573
|
-
#
|
|
574
|
-
#
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
candidate =
|
|
617
|
+
# Determine the project root for local-package discovery. Priority order:
|
|
618
|
+
# 1. The engine's scan root, if plumbed in (`self._scan_root`).
|
|
619
|
+
# 2. Walk up looking for any standard project marker. We accept a wide
|
|
620
|
+
# set so monorepos without pyproject.toml (e.g. Airflow data
|
|
621
|
+
# pipelines, data-team monorepos) still resolve correctly.
|
|
622
|
+
# 3. Fall back to the file's parent directory.
|
|
623
|
+
if self._scan_root is not None:
|
|
624
|
+
root = self._scan_root
|
|
625
|
+
else:
|
|
626
|
+
root = path.parent
|
|
627
|
+
candidate = root
|
|
628
|
+
for _ in range(8): # max 8 levels up
|
|
629
|
+
if any(
|
|
630
|
+
(candidate / marker).exists()
|
|
631
|
+
for marker in _PROJECT_ROOT_MARKERS
|
|
632
|
+
):
|
|
633
|
+
root = candidate
|
|
634
|
+
break
|
|
635
|
+
if candidate.parent == candidate:
|
|
636
|
+
break
|
|
637
|
+
candidate = candidate.parent
|
|
584
638
|
local = _local_packages(root)
|
|
585
639
|
|
|
586
640
|
findings = []
|
|
@@ -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.",
|
|
@@ -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
|
|
|
@@ -99,7 +98,11 @@ def _changed_files_all(root: Path) -> list[Path]:
|
|
|
99
98
|
return []
|
|
100
99
|
|
|
101
100
|
|
|
102
|
-
def _build_file_checkers(
|
|
101
|
+
def _build_file_checkers(
|
|
102
|
+
config: ProofctlConfig,
|
|
103
|
+
families: set[str] | None,
|
|
104
|
+
scan_root: Path | None = None,
|
|
105
|
+
) -> list:
|
|
103
106
|
checkers = []
|
|
104
107
|
|
|
105
108
|
def _include(family: str) -> bool:
|
|
@@ -121,6 +124,7 @@ def _build_file_checkers(config: ProofctlConfig, families: set[str] | None) -> l
|
|
|
121
124
|
local_namespaces=config.local_namespaces,
|
|
122
125
|
extra_indexes=config.pypi_extra_indexes,
|
|
123
126
|
check_pypi=config.check_pypi,
|
|
127
|
+
scan_root=scan_root,
|
|
124
128
|
))
|
|
125
129
|
if _include("S"):
|
|
126
130
|
checkers.append(SecurityChecker())
|
|
@@ -281,7 +285,7 @@ def run_check(
|
|
|
281
285
|
by_kind[kind].append(p)
|
|
282
286
|
|
|
283
287
|
# ── 4. Build per-format checker lists. ───────────────────────────────────
|
|
284
|
-
file_checkers = _build_file_checkers(config, fam_set)
|
|
288
|
+
file_checkers = _build_file_checkers(config, fam_set, scan_root=root)
|
|
285
289
|
dir_checkers = _build_dir_checkers(config, fam_set)
|
|
286
290
|
hcl_file_checkers = _build_hcl_file_checkers(config, fam_set)
|
|
287
291
|
hcl_dir_checkers = _build_hcl_dir_checkers(config, fam_set)
|
|
@@ -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>
|