diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b3374d4..74463a6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,7 +3,11 @@ updates: - package-ecosystem: pip directory: / schedule: - interval: weekly + interval: daily + cooldown: + semver-major-days: 30 + semver-minor-days: 7 + semver-patch-days: 3 groups: pip-patches: update-types: ["patch"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82e3ca4..c9a158a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,9 +17,9 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.13" cache: pip @@ -42,9 +42,9 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: ${{ matrix.python-version }} cache: pip @@ -57,7 +57,7 @@ jobs: - name: Upload coverage if: matrix.python-version == '3.13' - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5 with: files: coverage.xml token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0fcd6f6..52183c4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -15,15 +15,15 @@ jobs: analyze: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4 with: languages: python - name: Autobuild - uses: github/codeql-action/autobuild@v4 + uses: github/codeql-action/autobuild@38697555549f1db7851b81482ff19f1fa5c4fedc # v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e1551ad..7b0a4df 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,9 +13,9 @@ jobs: environment: pypi steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.13" @@ -25,4 +25,4 @@ jobs: python -m build - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@106e0b0b7c337fa67ed433972f777c6357f78598 # v1.13.0 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 2e53049..7f19243 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -18,18 +18,18 @@ jobs: id-token: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - name: Run analysis - uses: ossf/scorecard-action@v2.4.3 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif publish_results: true - name: Upload to code-scanning - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4 with: sarif_file: results.sarif diff --git a/tests/test_architecture.py b/tests/test_architecture.py index 1ff06e4..9a91ef9 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -43,11 +43,7 @@ def _source_files(exclude: set[str] | None = None) -> list[Path]: """All .py files in src/secretscreen/, excluding specified filenames.""" exclude = exclude or set() - return [ - p - for p in SRC.glob("*.py") - if p.name not in exclude and p.name != "__init__.py" - ] + return [p for p in SRC.glob("*.py") if p.name not in exclude and p.name != "__init__.py"] def _all_imports(tree: ast.Module) -> list[tuple[int, str]]: @@ -86,13 +82,9 @@ def test_detection_layers_no_cross_imports(self): # A layer importing itself is fine (relative import) if imported == path.name: continue - violations.append( - f"{path.name}:{node.lineno} imports {node.module}" - ) + violations.append(f"{path.name}:{node.lineno} imports {node.module}") assert not violations, ( - "Detection layer cross-import detected " - "(layers must be independently testable):\n" - + "\n".join(violations) + "Detection layer cross-import detected (layers must be independently testable):\n" + "\n".join(violations) ) def test_core_imports_all_layers(self): @@ -105,9 +97,7 @@ def test_core_imports_all_layers(self): imported_modules.add(node.module.split(".")[-1] + ".py") missing = DETECTION_LAYERS - imported_modules - assert not missing, ( - f"_core.py does not import detection layers: {missing}" - ) + assert not missing, f"_core.py does not import detection layers: {missing}" class TestZeroDependencies: @@ -128,12 +118,9 @@ def test_no_external_imports(self): continue if top_module in stdlib_names: continue - violations.append( - f"{path.name}:{lineno}: import {top_module}" - ) - assert not violations, ( - "External dependency detected (secretscreen must be zero-dependency):\n" - + "\n".join(violations) + violations.append(f"{path.name}:{lineno}: import {top_module}") + assert not violations, "External dependency detected (secretscreen must be zero-dependency):\n" + "\n".join( + violations ) @@ -162,19 +149,12 @@ def test_frozen_where_required(self): func = decorator.func if isinstance(func, ast.Name) and func.id == "dataclass": frozen = any( - kw.arg == "frozen" - and isinstance(kw.value, ast.Constant) - and kw.value.value is True + kw.arg == "frozen" and isinstance(kw.value, ast.Constant) and kw.value.value is True for kw in decorator.keywords ) if not frozen: - violations.append( - f"{path.name}:{node.lineno}: {node.name} must be frozen" - ) - assert not violations, ( - "Required-frozen dataclass is mutable:\n" - + "\n".join(violations) - ) + violations.append(f"{path.name}:{node.lineno}: {node.name} must be frozen") + assert not violations, "Required-frozen dataclass is mutable:\n" + "\n".join(violations) def test_mutable_only_where_allowed(self): """Non-allowlisted dataclasses must be frozen.""" @@ -192,20 +172,15 @@ def test_mutable_only_where_allowed(self): if isinstance(func, ast.Name) and func.id == "dataclass": is_dataclass = True frozen = any( - kw.arg == "frozen" - and isinstance(kw.value, ast.Constant) - and kw.value.value is True + kw.arg == "frozen" and isinstance(kw.value, ast.Constant) and kw.value.value is True for kw in decorator.keywords ) elif isinstance(decorator, ast.Name) and decorator.id == "dataclass": is_dataclass = True if is_dataclass and not frozen and node.name not in self._MUTABLE_ALLOWED: - violations.append( - f"{path.name}:{node.lineno}: {node.name} is not frozen" - ) - assert not violations, ( - "Dataclass not frozen (add frozen=True or add to _MUTABLE_ALLOWED):\n" - + "\n".join(violations) + violations.append(f"{path.name}:{node.lineno}: {node.name} is not frozen") + assert not violations, "Dataclass not frozen (add frozen=True or add to _MUTABLE_ALLOWED):\n" + "\n".join( + violations ) @@ -229,8 +204,7 @@ def test_no_broad_except_outside_parsers(self): violations.append(f"{path.name}:{i}: {stripped}") assert not violations, ( "Broad except outside _parsers.py " - "(catch specific exceptions or move to _parsers.py):\n" - + "\n".join(violations) + "(catch specific exceptions or move to _parsers.py):\n" + "\n".join(violations) ) @@ -250,11 +224,7 @@ def test_no_logging_import(self): for alias in node.names: if alias.name == "logging": violations.append(f"{path.name}:{node.lineno}") - elif ( - isinstance(node, ast.ImportFrom) - and node.module - and node.module.startswith("logging") - ): + elif isinstance(node, ast.ImportFrom) and node.module and node.module.startswith("logging"): violations.append(f"{path.name}:{node.lineno}") assert not violations, ( "Logging import detected (security library must not log — " @@ -281,21 +251,10 @@ def test_no_side_effect_imports(self): tree = ast.parse(path.read_text()) for lineno, top_module in _all_imports(tree): if top_module in self._FORBIDDEN_MODULES: - violations.append( - f"{path.name}:{lineno}: import {top_module}" - ) - if ( - top_module == "importlib" - and (path.name, "importlib") not in self._ALLOWED_EXCEPTIONS - ): - violations.append( - f"{path.name}:{lineno}: import importlib " - "(only allowed in _formats.py)" - ) - assert not violations, ( - "Side-effect-capable import in detection module:\n" - + "\n".join(violations) - ) + violations.append(f"{path.name}:{lineno}: import {top_module}") + if top_module == "importlib" and (path.name, "importlib") not in self._ALLOWED_EXCEPTIONS: + violations.append(f"{path.name}:{lineno}: import importlib (only allowed in _formats.py)") + assert not violations, "Side-effect-capable import in detection module:\n" + "\n".join(violations) def test_no_pathlib_writes(self): """No Path.write_text/write_bytes calls.""" @@ -305,10 +264,7 @@ def test_no_pathlib_writes(self): for i, line in enumerate(source.splitlines(), 1): if ".write_text(" in line or ".write_bytes(" in line: violations.append(f"{path.name}:{i}: {line.strip()}") - assert not violations, ( - "Filesystem write in library module:\n" - + "\n".join(violations) - ) + assert not violations, "Filesystem write in library module:\n" + "\n".join(violations) class TestPublicAPI: @@ -347,6 +303,4 @@ def test_public_api_surface(self): errors.append(f"Missing from __all__: {missing}") if extra: errors.append(f"Unexpected in __all__: {extra}") - assert not errors, ( - "Public API surface mismatch:\n" + "\n".join(errors) - ) + assert not errors, "Public API surface mismatch:\n" + "\n".join(errors)