Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 }}
8 changes: 4 additions & 4 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 3 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
6 changes: 3 additions & 3 deletions .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
90 changes: 22 additions & 68 deletions tests/test_architecture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand All @@ -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
)


Expand Down Expand Up @@ -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."""
Expand All @@ -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
)


Expand All @@ -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)
)


Expand All @@ -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 — "
Expand All @@ -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."""
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Loading