Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98e0020e2f | ||
|
|
597a88ff25 | ||
|
|
93e39de079 | ||
|
|
fc6240b72e | ||
|
|
6dc9dca339 | ||
|
|
2c93cb3097 | ||
|
|
23338995bf | ||
|
|
119c114daa | ||
|
|
e5bd9456d4 | ||
|
|
a494e73e89 | ||
|
|
e0622eb087 |
No files matched your search
@@ -0,0 +1,130 @@
|
|||||||
|
name: Validate
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: validate-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
name: Python ${{ matrix.python-version }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
python-version:
|
||||||
|
- "3.11"
|
||||||
|
- "3.12"
|
||||||
|
- "3.13"
|
||||||
|
- "3.14"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- uses: actions/setup-python@v7
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- run: python -m pip install --upgrade pip
|
||||||
|
- run: python -m pip install -e ".[dev]"
|
||||||
|
- run: python -m pytest -q
|
||||||
|
|
||||||
|
dependency-bounds:
|
||||||
|
name: Dependencies (${{ matrix.mode }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- mode: floor
|
||||||
|
python-version: "3.11"
|
||||||
|
- mode: latest
|
||||||
|
python-version: "3.14"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- uses: actions/setup-python@v7
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- run: python -m pip install --upgrade pip
|
||||||
|
- if: matrix.mode == 'floor'
|
||||||
|
run: >-
|
||||||
|
python -m pip install
|
||||||
|
"cbor2==5.6.0"
|
||||||
|
"pyOpenSSL==23.1.0"
|
||||||
|
"pytest==8.0.0"
|
||||||
|
- if: matrix.mode == 'floor'
|
||||||
|
run: python -m pip install --no-deps -e .
|
||||||
|
- if: matrix.mode == 'latest'
|
||||||
|
run: python -m pip install -e ".[dev]"
|
||||||
|
- run: python -m pytest -q
|
||||||
|
|
||||||
|
package:
|
||||||
|
name: Package artifacts
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-python@v7
|
||||||
|
with:
|
||||||
|
python-version: "3.14"
|
||||||
|
- run: python -m pip install --upgrade pip
|
||||||
|
- run: python -m pip install build hatchling hatch-vcs
|
||||||
|
- run: python -m build
|
||||||
|
- run: python tools/check_distribution.py dist
|
||||||
|
- name: Install and import wheel
|
||||||
|
run: |
|
||||||
|
python -m venv "$RUNNER_TEMP/wheel-smoke"
|
||||||
|
"$RUNNER_TEMP/wheel-smoke/bin/python" -m pip install \
|
||||||
|
dist/*.whl
|
||||||
|
cd "$RUNNER_TEMP"
|
||||||
|
"$RUNNER_TEMP/wheel-smoke/bin/python" -I -c \
|
||||||
|
"from smartthings_local.protocol.dtls_session import DtlsCoapSession"
|
||||||
|
- name: Install and import sdist
|
||||||
|
run: |
|
||||||
|
python -m venv "$RUNNER_TEMP/sdist-smoke"
|
||||||
|
"$RUNNER_TEMP/sdist-smoke/bin/python" -m pip install \
|
||||||
|
dist/*.tar.gz
|
||||||
|
cd "$RUNNER_TEMP"
|
||||||
|
"$RUNNER_TEMP/sdist-smoke/bin/python" -I -c \
|
||||||
|
"from smartthings_local.ocf.state_cache import StateCache"
|
||||||
|
|
||||||
|
share-safety:
|
||||||
|
name: Share safety
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-python@v7
|
||||||
|
with:
|
||||||
|
python-version: "3.14"
|
||||||
|
- name: Select comparison base
|
||||||
|
id: comparison
|
||||||
|
env:
|
||||||
|
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$PR_BASE_SHA" ]; then
|
||||||
|
echo "sha=$PR_BASE_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
elif [ -n "$PUSH_BEFORE_SHA" ] && \
|
||||||
|
[ "$PUSH_BEFORE_SHA" != "0000000000000000000000000000000000000000" ]; then
|
||||||
|
echo "sha=$PUSH_BEFORE_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "sha=$(git rev-parse HEAD^)" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
- run: >-
|
||||||
|
python tools/check_share_safety.py
|
||||||
|
--changed-since "${{ steps.comparison.outputs.sha }}"
|
||||||
@@ -15,10 +15,10 @@ jobs:
|
|||||||
name: Build sdist + wheel
|
name: Build sdist + wheel
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # hatch-vcs needs full history + tags to derive the version
|
fetch-depth: 0 # hatch-vcs needs full history + tags to derive the version
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- run: python -m pip install --upgrade build
|
- run: python -m pip install --upgrade build
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
if ! ls dist/ | grep -q "smartthings_local-${version}"; then
|
if ! ls dist/ | grep -q "smartthings_local-${version}"; then
|
||||||
echo "Built artifacts do not match tag version ${version}"; exit 1
|
echo "Built artifacts do not match tag version ${version}"; exit 1
|
||||||
fi
|
fi
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: dist/
|
path: dist/
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
id-token: write # required for Trusted Publishing (OIDC)
|
id-token: write # required for Trusted Publishing (OIDC)
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: dist/
|
path: dist/
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ nmap's `open|filtered` can't tell a real DTLS server from a silent UDP port. Con
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Stateless liveness check: one ClientHello round trip, leaves no state on the device
|
# Stateless liveness check: one ClientHello round trip, leaves no state on the device
|
||||||
.venv/bin/python -m smartthings_local.protocol.dtls_probe "$APPLIANCE_IP" 49153 49154 49155 49156 --stateless
|
python -m smartthings_local.protocol.dtls_probe "$APPLIANCE_IP" 49153 49154 49155 49156 --stateless
|
||||||
```
|
```
|
||||||
|
|
||||||
`live` means a DTLS server answered its `HelloVerifyRequest` (that's your control port); `dead` means silent / not DTLS. Once you have the client cert (Part 2), drop `--stateless` to run the default *diagnostic* drive, which reports `completed` (cert accepted) or `rejected` with the server's fatal alert. An `unsupported_certificate` / `unknown_ca` alert is the signature of a newer OCF-PKI device that won't accept the AC14K_M cert. The same probe gates the bridge's own reconnect loop and auto-discovers the port when `OCF_PORT` is unset.
|
`live` means a DTLS server answered its `HelloVerifyRequest` (that's your control port); `dead` means silent / not DTLS. Once you have the client cert (Part 2), drop `--stateless` to run the default *diagnostic* drive, which reports `completed` (cert accepted) or `rejected` with the server's fatal alert. An `unsupported_certificate` / `unknown_ca` alert is the signature of a newer OCF-PKI device that won't accept the AC14K_M cert. The same probe gates the bridge's own reconnect loop and auto-discovers the port when `OCF_PORT` is unset.
|
||||||
@@ -171,6 +171,8 @@ Output in `./certs/`: `client_fullchain.pem` + `client.key`.
|
|||||||
|
|
||||||
Neither the UUID nor the AC14K_M bundle is hardcoded in this repo; both are fetched live each run, so the script self-updates if upstream rotates. If either fetch fails, the script prints an inline workaround: supply the UUID via `UUID=<uuid>` env, or supply the AC14K_M bundle via `AC14K_M_CERT_BUNDLE=/path/to/cert.pem`. `BRAYSTORM_URL=<mirror>` points at a different bundle source.
|
Neither the UUID nor the AC14K_M bundle is hardcoded in this repo; both are fetched live each run, so the script self-updates if upstream rotates. If either fetch fails, the script prints an inline workaround: supply the UUID via `UUID=<uuid>` env, or supply the AC14K_M bundle via `AC14K_M_CERT_BUNDLE=/path/to/cert.pem`. `BRAYSTORM_URL=<mirror>` points at a different bundle source.
|
||||||
|
|
||||||
|
On Fedora/RHEL (and other hardened OpenSSL 3.x builds) the default crypto policy blocks SHA-1 signing, which step 5 needs. The script detects this, retries the signing step once with SHA-1 force-enabled for just that command, and only fails if the retry also fails. If it does, it prints the remedy: `sudo update-crypto-policies --set DEFAULT:SHA1` (undo afterward with `sudo update-crypto-policies --set DEFAULT`).
|
||||||
|
|
||||||
### How durable is this?
|
### How durable is this?
|
||||||
|
|
||||||
Rotating the published UUID would require Samsung to re-issue TLS certs across their IoT cloud, push new ACLs to every device in the field, and update the on-device daemon identity: a multi-quarter change with a long backwards-compat tail. `AC14K_M` has been public for years and is still in 2026 firmware trust stores. Local access via this path is roughly as durable as cloud control of these appliances.
|
Rotating the published UUID would require Samsung to re-issue TLS certs across their IoT cloud, push new ACLs to every device in the field, and update the on-device daemon identity: a multi-quarter change with a long backwards-compat tail. `AC14K_M` has been public for years and is still in 2026 firmware trust stores. Local access via this path is roughly as durable as cloud control of these appliances.
|
||||||
@@ -400,6 +402,7 @@ smartthings_local/ The installable library — `pip install sm
|
|||||||
__init__.py
|
__init__.py
|
||||||
coap.py CoAP wire protocol: message encode/decode, token handling
|
coap.py CoAP wire protocol: message encode/decode, token handling
|
||||||
dtls_session.py DTLS session: handshake, client-cert auth (file or in-memory PEM), Block2, liveness
|
dtls_session.py DTLS session: handshake, client-cert auth (file or in-memory PEM), Block2, liveness
|
||||||
|
dtls_probe.py DTLS ClientHello liveness probe (stateless gate + diagnostic mode)
|
||||||
ocf_root_ca.pem Samsung OCF root CA, bundled for handshake verification
|
ocf_root_ca.pem Samsung OCF root CA, bundled for handshake verification
|
||||||
ocf/ OCF resource + state layer (reusable)
|
ocf/ OCF resource + state layer (reusable)
|
||||||
__init__.py
|
__init__.py
|
||||||
@@ -426,7 +429,7 @@ mqtt_demo/ MQTT bridge demo (consumes smartthings_loca
|
|||||||
.env.example Template — copy to .env, fill in
|
.env.example Template — copy to .env, fill in
|
||||||
setup_cert.py One-shot cert minting script (live-fetches AC14K_M + UUID)
|
setup_cert.py One-shot cert minting script (live-fetches AC14K_M + UUID)
|
||||||
pyproject.toml Packaging — PyPI dist `smartthings-local`, hatch-vcs versioning
|
pyproject.toml Packaging — PyPI dist `smartthings-local`, hatch-vcs versioning
|
||||||
tests/ pytest suite (CoAP wire, state cache, import isolation, cert loading)
|
tests/ pytest suite (CoAP wire, state cache, import isolation, cert loading, DTLS probe, bridge port resolution, cert signing)
|
||||||
.github/workflows/publish.yml Build + PyPI Trusted Publishing on `v*` tags
|
.github/workflows/publish.yml Build + PyPI Trusted Publishing on `v*` tags
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cbor2>=5.6",
|
"cbor2>=5.6",
|
||||||
"pyOpenSSL>=23.0",
|
"pyOpenSSL>=23.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
+81
-7
@@ -184,8 +184,57 @@ def verify_cert_key_pair(cert_path, key_path):
|
|||||||
f"AC14K_M cert and key do not pair (cert modulus != key modulus)")
|
f"AC14K_M cert and key do not pair (cert modulus != key modulus)")
|
||||||
|
|
||||||
|
|
||||||
|
# OpenSSL config that force-enables SHA-1 signatures. Fedora/RHEL (and some
|
||||||
|
# other hardened OpenSSL 3.x builds) reject SHA-1 signing under the default
|
||||||
|
# crypto policy, but the AC14K_M trust chain requires a SHA-1-signed leaf, so
|
||||||
|
# we re-enable it just for the signing step via a scoped OPENSSL_CONF.
|
||||||
|
SHA1_OVERRIDE_CONF = """\
|
||||||
|
openssl_conf = openssl_init
|
||||||
|
|
||||||
|
[openssl_init]
|
||||||
|
alg_section = evp_properties
|
||||||
|
|
||||||
|
[evp_properties]
|
||||||
|
rh-allow-sha1-signatures = yes
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CommandError(RuntimeError):
|
||||||
|
"""A subprocess exited non-zero; carries the command and its output."""
|
||||||
|
|
||||||
|
|
||||||
def run(cmd, **kw):
|
def run(cmd, **kw):
|
||||||
return subprocess.run(cmd, check=True, capture_output=True, text=True, **kw)
|
proc = subprocess.run(cmd, capture_output=True, text=True, **kw)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
detail = (proc.stderr or proc.stdout or '').strip()
|
||||||
|
raise CommandError(
|
||||||
|
f"command failed (exit {proc.returncode}): {' '.join(cmd)}"
|
||||||
|
+ (f"\n{detail}" if detail else ""))
|
||||||
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
def run_allow_sha1(cmd):
|
||||||
|
"""Run an openssl command with SHA-1 signatures force-enabled, for
|
||||||
|
distros whose crypto policy otherwise blocks SHA-1 signing."""
|
||||||
|
version = run(['openssl', 'version']).stdout.strip()
|
||||||
|
if not version.startswith('OpenSSL 3.'):
|
||||||
|
# The provider configuration below is specific to OpenSSL 3.
|
||||||
|
# LibreSSL can exit successfully without running the requested
|
||||||
|
# command when it is given that configuration, leaving no output
|
||||||
|
# certificate behind. Older OpenSSL releases do not need the
|
||||||
|
# provider override either, so retry them with a clean environment.
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop('OPENSSL_CONF', None)
|
||||||
|
return run(cmd, env=env)
|
||||||
|
|
||||||
|
conf = tempfile.NamedTemporaryFile(
|
||||||
|
'w', suffix='.cnf', prefix='sha1_ok_', delete=False)
|
||||||
|
conf.write(SHA1_OVERRIDE_CONF)
|
||||||
|
conf.close()
|
||||||
|
try:
|
||||||
|
return run(cmd, env=dict(os.environ, OPENSSL_CONF=conf.name))
|
||||||
|
finally:
|
||||||
|
os.unlink(conf.name)
|
||||||
|
|
||||||
|
|
||||||
def mint_cert(uuid, ac14k_cert, ac14k_key, chain_files, out_dir):
|
def mint_cert(uuid, ac14k_cert, ac14k_key, chain_files, out_dir):
|
||||||
@@ -229,11 +278,23 @@ DNS.1 = {uuid}
|
|||||||
run(['openssl', 'req', '-new', '-key', str(paths['key']),
|
run(['openssl', 'req', '-new', '-key', str(paths['key']),
|
||||||
'-out', str(paths['csr']), '-subj', subject])
|
'-out', str(paths['csr']), '-subj', subject])
|
||||||
|
|
||||||
run(['openssl', 'x509', '-req', '-in', str(paths['csr']),
|
sign_cmd = ['openssl', 'x509', '-req', '-in', str(paths['csr']),
|
||||||
'-CA', str(ac14k_cert), '-CAkey', str(ac14k_key),
|
'-CA', str(ac14k_cert), '-CAkey', str(ac14k_key),
|
||||||
'-CAcreateserial', '-CAserial', str(paths['srl']),
|
'-CAcreateserial', '-CAserial', str(paths['srl']),
|
||||||
'-out', str(paths['leaf']), '-days', '3650',
|
'-out', str(paths['leaf']), '-days', '3650',
|
||||||
'-extfile', str(paths['ext']), '-sha1'])
|
'-extfile', str(paths['ext']), '-sha1']
|
||||||
|
try:
|
||||||
|
run(sign_cmd)
|
||||||
|
except CommandError as first:
|
||||||
|
# Most likely the local crypto policy blocks SHA-1 signing
|
||||||
|
# (common on Fedora/RHEL). Retry once with SHA-1 force-enabled;
|
||||||
|
# if that still fails, surface the original error.
|
||||||
|
print(" SHA-1 signing was rejected by the local OpenSSL policy; "
|
||||||
|
"retrying with a SHA-1 override...")
|
||||||
|
try:
|
||||||
|
run_allow_sha1(sign_cmd)
|
||||||
|
except CommandError:
|
||||||
|
raise first
|
||||||
|
|
||||||
parts = [paths['leaf'].read_text()]
|
parts = [paths['leaf'].read_text()]
|
||||||
for p in chain_files:
|
for p in chain_files:
|
||||||
@@ -459,7 +520,20 @@ def main():
|
|||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print(f"Phase 3: mint client cert with UUID {uuid}")
|
print(f"Phase 3: mint client cert with UUID {uuid}")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
paths = mint_cert(uuid, ac14k_cert, ac14k_key, chain_files, out_dir)
|
try:
|
||||||
|
paths = mint_cert(uuid, ac14k_cert, ac14k_key, chain_files, out_dir)
|
||||||
|
except CommandError as e:
|
||||||
|
print(f"\n[!] Failed to mint the client cert:\n{e}", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"\n If the failure mentions SHA-1 / disabled digests, your "
|
||||||
|
"OpenSSL build blocks SHA-1 signing (common on Fedora/RHEL).\n"
|
||||||
|
" The AC14K_M chain requires SHA-1, so allow it and re-run:\n"
|
||||||
|
" sudo update-crypto-policies --set DEFAULT:SHA1\n"
|
||||||
|
" (or LEGACY). Undo afterwards with: "
|
||||||
|
"sudo update-crypto-policies --set DEFAULT",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 4
|
||||||
|
|
||||||
print(f" key: {paths['key']}")
|
print(f" key: {paths['key']}")
|
||||||
print(f" leaf: {paths['leaf']}")
|
print(f" leaf: {paths['leaf']}")
|
||||||
print(f" fullchain: {paths['fullchain']}")
|
print(f" fullchain: {paths['fullchain']}")
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Compatibility baseline for the published API and LocalThings consumer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
|
||||||
|
from smartthings_local.ocf.state_cache import StateCache
|
||||||
|
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_compatible_signature(callable_object, expected: list[str]) -> None:
|
||||||
|
"""Require the existing call surface while allowing safe extensions."""
|
||||||
|
parameters = list(inspect.signature(callable_object).parameters.values())
|
||||||
|
assert [parameter.name for parameter in parameters[: len(expected)]] == expected
|
||||||
|
for parameter in parameters[len(expected) :]:
|
||||||
|
assert (
|
||||||
|
parameter.kind
|
||||||
|
in (
|
||||||
|
inspect.Parameter.VAR_POSITIONAL,
|
||||||
|
inspect.Parameter.VAR_KEYWORD,
|
||||||
|
)
|
||||||
|
or parameter.default is not inspect.Parameter.empty
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dtls_session_constructor_keeps_file_memory_and_local_port_inputs():
|
||||||
|
_assert_compatible_signature(
|
||||||
|
DtlsCoapSession,
|
||||||
|
[
|
||||||
|
"host",
|
||||||
|
"port",
|
||||||
|
"cert_path",
|
||||||
|
"key_path",
|
||||||
|
"cert_pem",
|
||||||
|
"key_pem",
|
||||||
|
"on_notification",
|
||||||
|
"mtu",
|
||||||
|
"rate_limit_rps",
|
||||||
|
"local_port",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dtls_session_keeps_current_consumer_methods():
|
||||||
|
expected = {
|
||||||
|
"close",
|
||||||
|
"connect",
|
||||||
|
"get",
|
||||||
|
"join",
|
||||||
|
"pace",
|
||||||
|
"ping",
|
||||||
|
"post",
|
||||||
|
"refresh_observes",
|
||||||
|
"start_reader",
|
||||||
|
"subscribe",
|
||||||
|
}
|
||||||
|
assert expected <= set(dir(DtlsCoapSession))
|
||||||
|
_assert_compatible_signature(
|
||||||
|
DtlsCoapSession.get,
|
||||||
|
[
|
||||||
|
"self",
|
||||||
|
"path_segs",
|
||||||
|
"query",
|
||||||
|
"timeout",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
_assert_compatible_signature(
|
||||||
|
DtlsCoapSession.post,
|
||||||
|
[
|
||||||
|
"self",
|
||||||
|
"path_segs",
|
||||||
|
"body_cbor",
|
||||||
|
"timeout",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
_assert_compatible_signature(
|
||||||
|
DtlsCoapSession.subscribe,
|
||||||
|
["self", "path_segs"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_cache_keeps_current_consumer_surface():
|
||||||
|
_assert_compatible_signature(StateCache, ["descriptor"])
|
||||||
|
expected = {
|
||||||
|
"apply_optimistic",
|
||||||
|
"apply_rep",
|
||||||
|
"freshness_s",
|
||||||
|
"get",
|
||||||
|
"index_device_tree",
|
||||||
|
"set_on_change",
|
||||||
|
"snapshot",
|
||||||
|
"stalest",
|
||||||
|
}
|
||||||
|
assert expected <= set(dir(StateCache))
|
||||||
|
|
||||||
|
|
||||||
|
def test_observe_refresh_task_keeps_current_consumer_surface():
|
||||||
|
_assert_compatible_signature(
|
||||||
|
ObserveRefreshTask,
|
||||||
|
[
|
||||||
|
"session",
|
||||||
|
"paths",
|
||||||
|
"interval_s",
|
||||||
|
"logger",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
_assert_compatible_signature(
|
||||||
|
ObserveRefreshTask.run_forever,
|
||||||
|
["self", "stop"],
|
||||||
|
)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import setup_cert
|
||||||
|
|
||||||
|
# All of these drive the real `openssl` CLI the way setup_cert does.
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
shutil.which("openssl") is None, reason="openssl CLI not available")
|
||||||
|
|
||||||
|
UUID = "04700f20-1111-2222-3333-444455556666"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ca(dir_path):
|
||||||
|
"""A throwaway self-signed CA standing in for the AC14K_M signer."""
|
||||||
|
cert = dir_path / "ca.pem"
|
||||||
|
key = dir_path / "ca.key"
|
||||||
|
subprocess.run(
|
||||||
|
["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||||
|
"-keyout", str(key), "-out", str(cert), "-days", "1",
|
||||||
|
"-subj", "/CN=AC14K_M"],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
return cert, key
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_cert_produces_sha1_leaf_with_uuid(tmp_path):
|
||||||
|
ca_cert, ca_key = _make_ca(tmp_path)
|
||||||
|
paths = setup_cert.mint_cert(
|
||||||
|
UUID, ca_cert, ca_key, [ca_cert], tmp_path / "out")
|
||||||
|
|
||||||
|
for name in ("key", "leaf", "fullchain"):
|
||||||
|
assert paths[name].exists() and paths[name].stat().st_size > 0
|
||||||
|
|
||||||
|
text = subprocess.run(
|
||||||
|
["openssl", "x509", "-in", str(paths["leaf"]), "-noout", "-text"],
|
||||||
|
check=True, capture_output=True, text=True).stdout
|
||||||
|
assert "sha1WithRSAEncryption" in text # SHA-1 signed leaf
|
||||||
|
assert f"URI:urn:uuid:{UUID}" in text # UUID in the SAN
|
||||||
|
assert "1.3.6.1.4.1.51414" in text # custom OIDs parsed
|
||||||
|
# fullchain is leaf + supplied chain
|
||||||
|
assert paths["fullchain"].read_text().count("BEGIN CERTIFICATE") == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_cert_surfaces_openssl_error(tmp_path):
|
||||||
|
"""A genuine signing failure raises CommandError carrying openssl's
|
||||||
|
output, instead of a bare non-zero-exit traceback."""
|
||||||
|
ca_cert, _ = _make_ca(tmp_path)
|
||||||
|
with pytest.raises(setup_cert.CommandError) as exc:
|
||||||
|
setup_cert.mint_cert(
|
||||||
|
UUID, ca_cert, tmp_path / "missing.key", [ca_cert],
|
||||||
|
tmp_path / "out")
|
||||||
|
assert "command failed" in str(exc.value)
|
||||||
|
assert len(str(exc.value)) > 40 # includes detail, not just an exit code
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_cert_retries_when_sha1_signing_blocked(tmp_path, monkeypatch):
|
||||||
|
"""Simulate a Fedora/RHEL crypto policy rejecting SHA-1: the first
|
||||||
|
(plain) signing attempt fails, and the SHA-1-override retry recovers."""
|
||||||
|
ca_cert, ca_key = _make_ca(tmp_path)
|
||||||
|
real_run = setup_cert.run
|
||||||
|
attempts = {"plain": 0}
|
||||||
|
|
||||||
|
def fake_run(cmd, **kw):
|
||||||
|
# Only the plain attempt has no OPENSSL_CONF override in its env.
|
||||||
|
if cmd[:3] == ["openssl", "x509", "-req"] and "env" not in kw:
|
||||||
|
attempts["plain"] += 1
|
||||||
|
raise setup_cert.CommandError(
|
||||||
|
"error: sha1 signature disabled by crypto policy")
|
||||||
|
return real_run(cmd, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(setup_cert, "run", fake_run)
|
||||||
|
paths = setup_cert.mint_cert(
|
||||||
|
UUID, ca_cert, ca_key, [ca_cert], tmp_path / "out")
|
||||||
|
|
||||||
|
assert attempts["plain"] == 1 # the plain path was exercised
|
||||||
|
assert paths["leaf"].exists() # the override retry recovered
|
||||||
|
|
||||||
|
|
||||||
|
def test_sha1_retry_does_not_give_openssl_3_config_to_libressl(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run(cmd, **kw):
|
||||||
|
calls.append((cmd, kw))
|
||||||
|
if cmd == ["openssl", "version"]:
|
||||||
|
return subprocess.CompletedProcess(cmd, 0, "LibreSSL 3.3.6\n", "")
|
||||||
|
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||||
|
|
||||||
|
monkeypatch.setattr(setup_cert, "run", fake_run)
|
||||||
|
monkeypatch.setenv("OPENSSL_CONF", "/synthetic/inherited.cnf")
|
||||||
|
|
||||||
|
setup_cert.run_allow_sha1(["openssl", "x509", "-req"])
|
||||||
|
|
||||||
|
assert calls[1][0] == ["openssl", "x509", "-req"]
|
||||||
|
assert "OPENSSL_CONF" not in calls[1][1]["env"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_error_includes_stderr():
|
||||||
|
with pytest.raises(setup_cert.CommandError) as exc:
|
||||||
|
setup_cert.run(["openssl", "x509", "-in", "/no/such/file"])
|
||||||
|
assert "command failed" in str(exc.value)
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from tools import check_share_safety
|
||||||
|
|
||||||
|
|
||||||
|
def test_documentation_addresses_and_synthetic_uuid_are_safe():
|
||||||
|
text = (
|
||||||
|
"192.0.2.10 198.51.100.20 203.0.113.30 "
|
||||||
|
"2001:db8::10 11111111-2222-3333-4444-555555555555"
|
||||||
|
)
|
||||||
|
assert check_share_safety.scan_text("fixture.txt", text) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dotted_object_identifiers_are_not_ipv4_addresses():
|
||||||
|
text = "extendedKeyUsage = 1.3.6.1.4.1.51414.0.1.2"
|
||||||
|
|
||||||
|
assert check_share_safety.scan_text("fixture.txt", text) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_github_attachment_uuid_is_safe_but_bare_uuid_is_not():
|
||||||
|
value = "cc1dca15-f272-4625-" + "a13c-2dc82283ff95"
|
||||||
|
public_url = f"https://github.com/user-attachments/assets/{value}"
|
||||||
|
|
||||||
|
assert check_share_safety.scan_text("README.md", public_url) == []
|
||||||
|
assert check_share_safety.scan_text("fixture.txt", value) == [
|
||||||
|
check_share_safety.Finding("fixture.txt", 1, "UUID")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_findings_never_echo_matched_content():
|
||||||
|
cases = {
|
||||||
|
"PEM_PRIVATE_KEY": "-----BEGIN " + "PRIVATE KEY-----",
|
||||||
|
"EMAIL_ADDRESS": "person" + "@example.net",
|
||||||
|
"MAC_ADDRESS": "aa:bb:cc:" + "dd:ee:ff",
|
||||||
|
"NON_DOCUMENTATION_IPV4": "10." + "24.8.9",
|
||||||
|
"NON_DOCUMENTATION_IPV6": "fd00" + 2 * chr(58) + "1234",
|
||||||
|
"PRIVATE_DNS": "appliance" + chr(46) + "house" + chr(46) + "local",
|
||||||
|
"HOME_PATH": "/" + "Users/person/private.txt",
|
||||||
|
"CREDENTIAL_URL": "https://user:" + "pass" + chr(64) + "example.net/data",
|
||||||
|
"SECRET_ASSIGNMENT": (
|
||||||
|
"access_token " + chr(61) + " " + chr(34) + "never-print-this" + chr(34)
|
||||||
|
),
|
||||||
|
"SERIAL_ASSIGNMENT": (
|
||||||
|
"serialNumber " + chr(61) + " " + chr(34) + "device-123456" + chr(34)
|
||||||
|
),
|
||||||
|
"REAL_TIMESTAMP": "2026-08-02" + "T12:34:56Z",
|
||||||
|
"QR_PAYLOAD": "qr_" + "payload = value",
|
||||||
|
"UUID": "12345678-1234-4234-9234-" + "123456789abc",
|
||||||
|
}
|
||||||
|
for rule_id, value in cases.items():
|
||||||
|
findings = check_share_safety.scan_text("candidate.txt", value)
|
||||||
|
rendered = "\n".join(finding.render() for finding in findings)
|
||||||
|
assert f"candidate.txt:1:{rule_id}" in rendered
|
||||||
|
assert value not in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_binary_and_archive_inputs_are_rejected(tmp_path):
|
||||||
|
binary = tmp_path / "fixture.bin"
|
||||||
|
binary.write_bytes(b"before\x00after")
|
||||||
|
capture = tmp_path / "fixture.pcap"
|
||||||
|
capture.write_text("text-looking content")
|
||||||
|
|
||||||
|
assert check_share_safety.scan_file(binary, "fixture.bin") == [
|
||||||
|
check_share_safety.Finding("fixture.bin", 0, "BINARY_CONTENT")
|
||||||
|
]
|
||||||
|
assert check_share_safety.scan_file(capture, "fixture.pcap") == [
|
||||||
|
check_share_safety.Finding("fixture.pcap", 0, "FORBIDDEN_FILE_TYPE")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_changed_paths_include_staged_unstaged_and_untracked_files(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
def git(*args):
|
||||||
|
return subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-c",
|
||||||
|
"commit.gpgsign=false",
|
||||||
|
"-c",
|
||||||
|
"user.name=Test",
|
||||||
|
"-c",
|
||||||
|
"user.email=" + "test" + chr(64) + "example.invalid",
|
||||||
|
*args,
|
||||||
|
],
|
||||||
|
cwd=tmp_path,
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
git("init", "--quiet")
|
||||||
|
baseline = tmp_path / "baseline.txt"
|
||||||
|
baseline.write_text("before\n")
|
||||||
|
git("add", "baseline.txt")
|
||||||
|
git("commit", "--quiet", "-m", "baseline")
|
||||||
|
base = git("rev-parse", "HEAD").stdout.strip()
|
||||||
|
|
||||||
|
staged = tmp_path / "staged.txt"
|
||||||
|
staged.write_text("staged\n")
|
||||||
|
git("add", "staged.txt")
|
||||||
|
baseline.write_text("after\n")
|
||||||
|
(tmp_path / "untracked.txt").write_text("untracked\n")
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
assert check_share_safety._changed_paths(base) == [
|
||||||
|
"baseline.txt",
|
||||||
|
"staged.txt",
|
||||||
|
"untracked.txt",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_committed_scan_ignores_unchanged_findings_but_checks_added_lines(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
def git(*args):
|
||||||
|
return subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-c",
|
||||||
|
"commit.gpgsign=false",
|
||||||
|
"-c",
|
||||||
|
"user.name=Test",
|
||||||
|
"-c",
|
||||||
|
"user.email=" + "test" + chr(64) + "example.invalid",
|
||||||
|
*args,
|
||||||
|
],
|
||||||
|
cwd=tmp_path,
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate = tmp_path / "candidate.txt"
|
||||||
|
private_one = "10." + "24.8.9"
|
||||||
|
private_two = "10." + "24.8.10"
|
||||||
|
git("init", "--quiet")
|
||||||
|
candidate.write_text(f"existing {private_one}\n")
|
||||||
|
git("add", "candidate.txt")
|
||||||
|
git("commit", "--quiet", "-m", "baseline")
|
||||||
|
base = git("rev-parse", "HEAD").stdout.strip()
|
||||||
|
|
||||||
|
candidate.write_text(f"existing {private_one}\nsafe addition\n")
|
||||||
|
git("add", "candidate.txt")
|
||||||
|
git("commit", "--quiet", "-m", "safe change")
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
assert check_share_safety.check_changed(base) == []
|
||||||
|
|
||||||
|
candidate.write_text(
|
||||||
|
f"existing {private_one}\nsafe addition\nintroduced {private_two}\n"
|
||||||
|
)
|
||||||
|
git("add", "candidate.txt")
|
||||||
|
git("commit", "--quiet", "-m", "unsafe change")
|
||||||
|
assert check_share_safety.check_changed(base) == [
|
||||||
|
check_share_safety.Finding("candidate.txt", 3, "NON_DOCUMENTATION_IPV4")
|
||||||
|
]
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Deterministic baseline checks for the current OCF worker stop contract."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from smartthings_local.ocf.keepalive import KeepaliveTask
|
||||||
|
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
|
||||||
|
from smartthings_local.ocf.poll_scheduler import PollScheduler, PollTier
|
||||||
|
from smartthings_local.ocf.state_cache import StateCache
|
||||||
|
|
||||||
|
_THREAD_DEADLINE_S = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
def ping(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def refresh_observes(self, paths):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _Descriptor:
|
||||||
|
def on_observation(self, state, href, rep):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _ObservedEvent(threading.Event):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.waiting = threading.Event()
|
||||||
|
|
||||||
|
def wait(self, timeout=None):
|
||||||
|
self.waiting.set()
|
||||||
|
return super().wait(timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_worker_stops(target, name: str):
|
||||||
|
stop = _ObservedEvent()
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
target(stop)
|
||||||
|
except Exception as error: # noqa: BLE001 # pragma: no cover
|
||||||
|
errors.append(type(error).__name__)
|
||||||
|
|
||||||
|
worker = threading.Thread(target=run, name=name, daemon=True)
|
||||||
|
worker.start()
|
||||||
|
assert stop.waiting.wait(_THREAD_DEADLINE_S), (
|
||||||
|
f"{name} did not enter an interruptible wait"
|
||||||
|
)
|
||||||
|
stop.set()
|
||||||
|
worker.join(_THREAD_DEADLINE_S)
|
||||||
|
assert not worker.is_alive(), f"{name} did not stop"
|
||||||
|
assert errors == [], f"{name} raised {errors[0]}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_keepalive_worker_stops_without_waiting_for_interval():
|
||||||
|
task = KeepaliveTask(_Session(), interval_s=3600.0)
|
||||||
|
_assert_worker_stops(task.run_forever, "test-keepalive")
|
||||||
|
|
||||||
|
|
||||||
|
def test_observe_refresh_worker_stops_without_waiting_for_interval():
|
||||||
|
task = ObserveRefreshTask(_Session(), [], interval_s=3600.0)
|
||||||
|
_assert_worker_stops(task.run_forever, "test-observe-refresh")
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_scheduler_worker_stops_without_leaking_thread():
|
||||||
|
scheduler = PollScheduler(
|
||||||
|
_Session(),
|
||||||
|
StateCache(_Descriptor()),
|
||||||
|
[PollTier("idle", interval_s=3600.0, paths=())],
|
||||||
|
)
|
||||||
|
_assert_worker_stops(scheduler.run_forever, "test-poll-scheduler")
|
||||||
Executable
+128
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify that SmartThings-Local wheel and sdist contents are intentional."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionError(RuntimeError):
|
||||||
|
"""An artifact contains a missing, unexpected, or unsafe member."""
|
||||||
|
|
||||||
|
|
||||||
|
def _tracked_files() -> set[str]:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["git", "ls-files", "-z", "--", "smartthings_local", "tests"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return {value.decode("utf-8") for value in proc.stdout.split(b"\0") if value}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_member(name: str) -> bool:
|
||||||
|
path = PurePosixPath(name)
|
||||||
|
return bool(name) and not path.is_absolute() and ".." not in path.parts
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_package_files() -> set[str]:
|
||||||
|
tracked = {
|
||||||
|
path for path in _tracked_files() if path.startswith("smartthings_local/")
|
||||||
|
}
|
||||||
|
tracked.add("smartthings_local/_version.py")
|
||||||
|
return tracked
|
||||||
|
|
||||||
|
|
||||||
|
def check_wheel(path: Path) -> None:
|
||||||
|
with zipfile.ZipFile(path) as archive:
|
||||||
|
names = set(archive.namelist())
|
||||||
|
if not names or any(not _safe_member(name) for name in names):
|
||||||
|
raise DistributionError("wheel has an unsafe member")
|
||||||
|
|
||||||
|
package_files = {name for name in names if name.startswith("smartthings_local/")}
|
||||||
|
if package_files != _expected_package_files():
|
||||||
|
raise DistributionError(
|
||||||
|
"wheel package contents differ from the tracked package"
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = names - package_files
|
||||||
|
roots = {name.split("/", 1)[0] for name in metadata}
|
||||||
|
if len(roots) != 1:
|
||||||
|
raise DistributionError("wheel must contain one dist-info directory")
|
||||||
|
dist_info = roots.pop()
|
||||||
|
if not dist_info.endswith(".dist-info"):
|
||||||
|
raise DistributionError("wheel metadata directory is invalid")
|
||||||
|
expected_metadata = {
|
||||||
|
f"{dist_info}/METADATA",
|
||||||
|
f"{dist_info}/WHEEL",
|
||||||
|
f"{dist_info}/licenses/LICENSE",
|
||||||
|
f"{dist_info}/RECORD",
|
||||||
|
}
|
||||||
|
if metadata != expected_metadata:
|
||||||
|
raise DistributionError("wheel metadata contents are unexpected")
|
||||||
|
|
||||||
|
|
||||||
|
def check_sdist(path: Path) -> None:
|
||||||
|
with tarfile.open(path, mode="r:gz") as archive:
|
||||||
|
members = archive.getmembers()
|
||||||
|
if not members or any(
|
||||||
|
not member.isfile() or member.issym() or member.islnk() for member in members
|
||||||
|
):
|
||||||
|
raise DistributionError("sdist must contain regular files only")
|
||||||
|
names = {member.name for member in members}
|
||||||
|
if any(not _safe_member(name) for name in names):
|
||||||
|
raise DistributionError("sdist has an unsafe member")
|
||||||
|
|
||||||
|
roots = {name.split("/", 1)[0] for name in names}
|
||||||
|
if len(roots) != 1:
|
||||||
|
raise DistributionError("sdist must contain one top-level directory")
|
||||||
|
root = roots.pop()
|
||||||
|
relative = {name[len(root) + 1 :] for name in names if name.startswith(f"{root}/")}
|
||||||
|
required = _tracked_files() | {
|
||||||
|
"LICENSE",
|
||||||
|
"PKG-INFO",
|
||||||
|
"README.md",
|
||||||
|
"pyproject.toml",
|
||||||
|
"smartthings_local/_version.py",
|
||||||
|
}
|
||||||
|
# hatchling bundles the VCS ignore files it finds, but which ones ship
|
||||||
|
# depends on the hatchling version (newer releases drop .hgignore), so
|
||||||
|
# treat them as optional rather than exact members.
|
||||||
|
optional = {".gitignore", ".hgignore"}
|
||||||
|
if not required <= relative <= required | optional:
|
||||||
|
raise DistributionError("sdist contents differ from the intended source set")
|
||||||
|
|
||||||
|
|
||||||
|
def check_directory(directory: Path) -> None:
|
||||||
|
wheels = sorted(directory.glob("*.whl"))
|
||||||
|
sdists = sorted(directory.glob("*.tar.gz"))
|
||||||
|
if len(wheels) != 1 or len(sdists) != 1:
|
||||||
|
raise DistributionError("expected exactly one wheel and one sdist")
|
||||||
|
check_wheel(wheels[0])
|
||||||
|
check_sdist(sdists[0])
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("directory", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
check_directory(args.directory)
|
||||||
|
except (
|
||||||
|
DistributionError,
|
||||||
|
OSError,
|
||||||
|
subprocess.SubprocessError,
|
||||||
|
tarfile.TarError,
|
||||||
|
zipfile.BadZipFile,
|
||||||
|
):
|
||||||
|
print("distribution check failed")
|
||||||
|
return 1
|
||||||
|
print("distribution contents verified")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+308
@@ -0,0 +1,308 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check introduced public content for common private-data and secret shapes.
|
||||||
|
|
||||||
|
Findings contain only path, line, and rule ID. Matched content is never
|
||||||
|
printed because it may itself be sensitive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
MAX_TEXT_BYTES = 2 * 1024 * 1024
|
||||||
|
DOCUMENTATION_IPV4 = tuple(
|
||||||
|
ipaddress.ip_network(value)
|
||||||
|
for value in ("192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24")
|
||||||
|
)
|
||||||
|
DOCUMENTATION_IPV6 = ipaddress.ip_network("2001:db8::/32")
|
||||||
|
SAFE_UUIDS = {
|
||||||
|
"00000000-0000-0000-0000-000000000000",
|
||||||
|
"11111111-2222-3333-4444-555555555555",
|
||||||
|
}
|
||||||
|
FORBIDDEN_SUFFIXES = {
|
||||||
|
".7z",
|
||||||
|
".apk",
|
||||||
|
".cap",
|
||||||
|
".db",
|
||||||
|
".der",
|
||||||
|
".gz",
|
||||||
|
".jks",
|
||||||
|
".key",
|
||||||
|
".p12",
|
||||||
|
".pcap",
|
||||||
|
".pcapng",
|
||||||
|
".pfx",
|
||||||
|
".sqlite",
|
||||||
|
".sqlite3",
|
||||||
|
".tar",
|
||||||
|
".tgz",
|
||||||
|
".zip",
|
||||||
|
}
|
||||||
|
|
||||||
|
PATTERNS = (
|
||||||
|
(
|
||||||
|
"PEM_PRIVATE_KEY",
|
||||||
|
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"EMAIL_ADDRESS",
|
||||||
|
re.compile(r"[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"MAC_ADDRESS",
|
||||||
|
re.compile(r"(?i)(?<![0-9a-f])(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}(?![0-9a-f])"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"PRIVATE_DNS",
|
||||||
|
re.compile(r"(?i)\b(?:[a-z0-9-]+\.)+(?:corp|home|internal|lan|local)\b"),
|
||||||
|
),
|
||||||
|
("HOME_PATH", re.compile(r"(?<![A-Za-z0-9._-])/(?:Users|home)/[^\s'\"`]+")),
|
||||||
|
(
|
||||||
|
"CREDENTIAL_URL",
|
||||||
|
re.compile(
|
||||||
|
r"(?i)\bhttps?://(?:[^\s/@:]+:[^\s/@]+@|[^\s?#]+[?&](?:access_token|api_key|password|refresh_token|token)=)"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"SECRET_ASSIGNMENT",
|
||||||
|
re.compile(
|
||||||
|
r"(?i)\b(?:access[_-]?token|api[_-]?key|bearer|owner[_-]?psk|password|passwd|private[_-]?key|psk|refresh[_-]?token|secret)\b\s*(?::|=)\s*(?:b|br|f|r|rb)?['\"][^'\"]+['\"]"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"SERIAL_ASSIGNMENT",
|
||||||
|
re.compile(
|
||||||
|
r"(?i)\b(?:device[_-]?)?serial(?:number|num)?\b\s*(?::|=)\s*['\"][^'\"]+['\"]"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"REAL_TIMESTAMP",
|
||||||
|
re.compile(
|
||||||
|
r"\b20[0-9]{2}-[01][0-9]-[0-3][0-9][T ][0-2][0-9]:[0-5][0-9](?::[0-6][0-9](?:\.[0-9]+)?)?(?:Z|[+-][0-2][0-9]:?[0-5][0-9])?\b"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"QR_PAYLOAD",
|
||||||
|
re.compile(r"(?i)\b(?:qr[_-]?payload|setup[_-]?payload)\b\s*(?::|=)"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
UUID_PATTERN = r"(?<![0-9a-f])[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(?![0-9a-f])"
|
||||||
|
UUID_RE = re.compile(UUID_PATTERN, re.IGNORECASE)
|
||||||
|
PUBLIC_GITHUB_ATTACHMENT_RE = re.compile(
|
||||||
|
rf"https://github\.com/user-attachments/assets/(?P<uuid>{UUID_PATTERN})",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
IPV4_RE = re.compile(
|
||||||
|
r"(?<![0-9.])(?:25[0-5]|2[0-4][0-9]|1?[0-9]{1,2})(?:\.(?:25[0-5]|2[0-4][0-9]|1?[0-9]{1,2})){3}(?![0-9.])"
|
||||||
|
)
|
||||||
|
IPV6_RE = re.compile(
|
||||||
|
r"(?i)(?<![0-9a-f:])(?:\[)?(?:[0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}(?:%[A-Za-z0-9_.-]+)?(?:\])?(?![0-9a-f:])"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, order=True)
|
||||||
|
class Finding:
|
||||||
|
path: str
|
||||||
|
line: int
|
||||||
|
rule_id: str
|
||||||
|
|
||||||
|
def render(self) -> str:
|
||||||
|
return f"{self.path}:{self.line}:{self.rule_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_ipv4(value: str) -> bool:
|
||||||
|
address = ipaddress.ip_address(value)
|
||||||
|
return (
|
||||||
|
address.is_loopback
|
||||||
|
or address.is_unspecified
|
||||||
|
or any(address in network for network in DOCUMENTATION_IPV4)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_ipv6(value: str) -> bool:
|
||||||
|
address = ipaddress.ip_address(value.strip("[]").split("%", 1)[0])
|
||||||
|
return (
|
||||||
|
address.is_loopback or address.is_unspecified or address in DOCUMENTATION_IPV6
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_text(path: str, text: str) -> list[Finding]:
|
||||||
|
findings: set[Finding] = set()
|
||||||
|
for line_number, line in enumerate(text.splitlines(), start=1):
|
||||||
|
public_attachment_uuids = {
|
||||||
|
match.group("uuid").lower()
|
||||||
|
for match in PUBLIC_GITHUB_ATTACHMENT_RE.finditer(line)
|
||||||
|
}
|
||||||
|
for rule_id, pattern in PATTERNS:
|
||||||
|
if pattern.search(line):
|
||||||
|
findings.add(Finding(path, line_number, rule_id))
|
||||||
|
for match in UUID_RE.finditer(line):
|
||||||
|
value = match.group(0).lower()
|
||||||
|
if value not in SAFE_UUIDS and value not in public_attachment_uuids:
|
||||||
|
findings.add(Finding(path, line_number, "UUID"))
|
||||||
|
for match in IPV4_RE.finditer(line):
|
||||||
|
if not _safe_ipv4(match.group(0)):
|
||||||
|
findings.add(Finding(path, line_number, "NON_DOCUMENTATION_IPV4"))
|
||||||
|
for match in IPV6_RE.finditer(line):
|
||||||
|
try:
|
||||||
|
safe = _safe_ipv6(match.group(0))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not safe:
|
||||||
|
findings.add(Finding(path, line_number, "NON_DOCUMENTATION_IPV6"))
|
||||||
|
return sorted(findings)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_file(path: Path, display_path: str) -> list[Finding]:
|
||||||
|
if path.is_symlink():
|
||||||
|
return [Finding(display_path, 0, "SYMLINK")]
|
||||||
|
if path.suffix.casefold() in FORBIDDEN_SUFFIXES:
|
||||||
|
return [Finding(display_path, 0, "FORBIDDEN_FILE_TYPE")]
|
||||||
|
data = path.read_bytes()
|
||||||
|
if len(data) > MAX_TEXT_BYTES:
|
||||||
|
return [Finding(display_path, 0, "FILE_TOO_LARGE")]
|
||||||
|
if b"\x00" in data:
|
||||||
|
return [Finding(display_path, 0, "BINARY_CONTENT")]
|
||||||
|
try:
|
||||||
|
text = data.decode("utf-8", errors="strict")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return [Finding(display_path, 0, "NON_UTF8_CONTENT")]
|
||||||
|
return scan_text(display_path, text)
|
||||||
|
|
||||||
|
|
||||||
|
def _changed_paths(base: str) -> list[str]:
|
||||||
|
commands = (
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"diff",
|
||||||
|
"--name-only",
|
||||||
|
"--diff-filter=ACMR",
|
||||||
|
"-z",
|
||||||
|
f"{base}..HEAD",
|
||||||
|
"--",
|
||||||
|
],
|
||||||
|
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z", "--"],
|
||||||
|
["git", "diff", "--name-only", "--diff-filter=ACMR", "-z", "--"],
|
||||||
|
)
|
||||||
|
changed = [
|
||||||
|
subprocess.run(command, capture_output=True, check=True) for command in commands
|
||||||
|
]
|
||||||
|
untracked = subprocess.run(
|
||||||
|
["git", "ls-files", "--others", "--exclude-standard", "-z"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return sorted(
|
||||||
|
{
|
||||||
|
value.decode("utf-8")
|
||||||
|
for output in (*(result.stdout for result in changed), untracked.stdout)
|
||||||
|
for value in output.split(b"\0")
|
||||||
|
if value
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _local_changed_paths() -> set[str]:
|
||||||
|
commands = (
|
||||||
|
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z", "--"],
|
||||||
|
["git", "diff", "--name-only", "--diff-filter=ACMR", "-z", "--"],
|
||||||
|
["git", "ls-files", "--others", "--exclude-standard", "-z"],
|
||||||
|
)
|
||||||
|
outputs = (
|
||||||
|
subprocess.run(command, capture_output=True, check=True).stdout
|
||||||
|
for command in commands
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
value.decode("utf-8")
|
||||||
|
for output in outputs
|
||||||
|
for value in output.split(b"\0")
|
||||||
|
if value
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(?P<start>\d+)(?:,(?P<count>\d+))? @@")
|
||||||
|
|
||||||
|
|
||||||
|
def _introduced_lines(base: str, path: str) -> set[int]:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"diff",
|
||||||
|
"--no-color",
|
||||||
|
"--no-ext-diff",
|
||||||
|
"--unified=0",
|
||||||
|
"--diff-filter=ACMR",
|
||||||
|
f"{base}..HEAD",
|
||||||
|
"--",
|
||||||
|
path,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
lines: set[int] = set()
|
||||||
|
for value in result.stdout.splitlines():
|
||||||
|
match = HUNK_RE.match(value)
|
||||||
|
if match is None:
|
||||||
|
continue
|
||||||
|
start = int(match.group("start"))
|
||||||
|
count = int(match.group("count") or 1)
|
||||||
|
lines.update(range(start, start + count))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def check_changed(base: str) -> list[Finding]:
|
||||||
|
"""Scan introduced committed lines and all local-only file content."""
|
||||||
|
local_paths = _local_changed_paths()
|
||||||
|
findings: list[Finding] = []
|
||||||
|
for path in _changed_paths(base):
|
||||||
|
path_findings = scan_file(Path(path), path)
|
||||||
|
if path in local_paths:
|
||||||
|
findings.extend(path_findings)
|
||||||
|
continue
|
||||||
|
introduced = _introduced_lines(base, path)
|
||||||
|
findings.extend(
|
||||||
|
finding
|
||||||
|
for finding in path_findings
|
||||||
|
if finding.line == 0 or finding.line in introduced
|
||||||
|
)
|
||||||
|
return sorted(set(findings))
|
||||||
|
|
||||||
|
|
||||||
|
def check_paths(paths: Iterable[str]) -> list[Finding]:
|
||||||
|
findings: list[Finding] = []
|
||||||
|
for value in paths:
|
||||||
|
findings.extend(scan_file(Path(value), value))
|
||||||
|
return sorted(set(findings))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("paths", nargs="*")
|
||||||
|
parser.add_argument("--changed-since")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
paths = _changed_paths(args.changed_since) if args.changed_since else args.paths
|
||||||
|
if not paths:
|
||||||
|
raise ValueError("no paths selected")
|
||||||
|
findings = (
|
||||||
|
check_changed(args.changed_since)
|
||||||
|
if args.changed_since
|
||||||
|
else check_paths(paths)
|
||||||
|
)
|
||||||
|
except (OSError, UnicodeDecodeError, subprocess.SubprocessError, ValueError):
|
||||||
|
print("share-safety scan failed closed:SCAN_ERROR")
|
||||||
|
return 2
|
||||||
|
for finding in findings:
|
||||||
|
print(finding.render())
|
||||||
|
return 1 if findings else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in new issue
Block a user