Author SHA1 Message Date
Jack Nagy 98e0020e2f fix(ci): treat sdist VCS-ignore files as optional in distribution check
hatchling 1.31.0 ships only .gitignore in the sdist; older releases also
bundled .hgignore. check_sdist required an exact member set including
.hgignore, so the Validate workflow's package job failed on main once CI
resolved the newer hatchling.

Require the tracked source set plus the fixed metadata files, and accept
.gitignore/.hgignore as optional members either way.
2026-08-03 19:45:09 +01:00
Quite Yellow 597a88ff25 Merge pull request #21 from Moballo-LLC/codex/py-01-ci-foundation
ci: add validation and package smoke tests
2026-08-03 19:33:28 +01:00
Quite Yellow 93e39de079 Merge pull request #22 from Moballo-LLC/codex/libressl-sha1-retry
fix(setup-cert): keep SHA-1 retry compatible with LibreSSL
2026-08-03 19:33:25 +01:00
Jason Morcos fc6240b72e test(safety): allow public GitHub attachments 2026-08-02 16:31:15 -07:00
Jason Morcos 6dc9dca339 fix(setup-cert): keep SHA-1 retry compatible with LibreSSL 2026-08-02 10:43:40 -07:00
Jason Morcos 2c93cb3097 test: make worker cleanup checks deterministic 2026-08-02 10:13:56 -07:00
Jason Morcos 23338995bf build: require pyOpenSSL DTLS timeout support 2026-08-02 10:07:49 -07:00
Jason Morcos 119c114daa ci: add pull request validation 2026-08-02 10:02:52 -07:00
Jack Nagy e5bd9456d4 docs(readme): document SHA-1 crypto-policy handling + list dtls_probe
- Note the Fedora/RHEL SHA-1 crypto-policy block in Part 2 and how
  setup_cert.py auto-retries / the manual update-crypto-policies remedy.
- Add dtls_probe.py to the repo-layout tree (it was referenced in three
  places but missing from the file listing).
- Drop the .venv/ prefix from the Part 1 probe command so it runs against
  the pip-installed package before the Part 4 venv exists.
- Expand the tests parenthetical to name the probe, port-resolution, and
  cert-signing suites.
2026-08-01 12:05:57 +01:00
Quite Yellow a494e73e89 fix(setup_cert): surface openssl errors and work around SHA-1 crypto policy (#19)
* fix(setup_cert): surface openssl errors and work around SHA-1 crypto policy

The signing step forces -sha1 (the AC14K_M chain requires SHA-1-signed
leaves), which Fedora/RHEL's default crypto policy rejects on OpenSSL
3.x. run() also swallowed stderr, so the failure surfaced as an opaque
non-zero-exit traceback with no diagnostic.

- run() now raises CommandError carrying the command and openssl stderr
- mint_cert retries signing with a scoped OPENSSL_CONF enabling
  rh-allow-sha1-signatures when the first attempt fails
- main() prints the update-crypto-policies fallback on failure

Fixes #15

* test(setup_cert): cover SHA-1 signing, error surfacing, and crypto-policy retry

Regression tests for the #15 fix:
- full mint_cert flow (SHA-1 leaf, UUID SAN, custom OIDs, chain assembly)
- CommandError surfaces openssl stderr on a genuine signing failure
- signing retries via the SHA-1 override when the plain attempt is blocked
- run() raises CommandError with detail
2026-08-01 11:47:09 +01:00
Quite Yellow e0622eb087 ci(publish): bump actions off deprecated Node 20 runtimes (#18)
GitHub is deprecating the Node 20 action runtime; checkout@v4,
setup-python@v5, and upload/download-artifact@v4 all run on it and
were being auto-forced to Node 24 with a warning. Bump each to its
current major (checkout@v7, setup-python@v7, upload-artifact@v7,
download-artifact@v8), all of which run natively on Node 24.
2026-08-01 11:28:14 +01:00
11 changed files with 1102 additions and 14 deletions
+130
View File
@@ -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 }}"
+4 -4
View File
@@ -15,10 +15,10 @@ jobs:
name: Build sdist + wheel
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0 # hatch-vcs needs full history + tags to derive the version
- uses: actions/setup-python@v5
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- run: python -m pip install --upgrade build
@@ -30,7 +30,7 @@ jobs:
if ! ls dist/ | grep -q "smartthings_local-${version}"; then
echo "Built artifacts do not match tag version ${version}"; exit 1
fi
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: dist
path: dist/
@@ -43,7 +43,7 @@ jobs:
permissions:
id-token: write # required for Trusted Publishing (OIDC)
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
name: dist
path: dist/
+5 -2
View File
@@ -88,7 +88,7 @@ nmap's `open|filtered` can't tell a real DTLS server from a silent UDP port. Con
```sh
# 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.
@@ -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.
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?
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
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_probe.py DTLS ClientHello liveness probe (stateless gate + diagnostic mode)
ocf_root_ca.pem Samsung OCF root CA, bundled for handshake verification
ocf/ OCF resource + state layer (reusable)
__init__.py
@@ -426,7 +429,7 @@ mqtt_demo/ MQTT bridge demo (consumes smartthings_loca
.env.example Template — copy to .env, fill in
setup_cert.py One-shot cert minting script (live-fetches AC14K_M + UUID)
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
```
+1 -1
View File
@@ -21,7 +21,7 @@ classifiers = [
]
dependencies = [
"cbor2>=5.6",
"pyOpenSSL>=23.0",
"pyOpenSSL>=23.1",
]
[project.urls]
+81 -7
View File
@@ -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)")
# 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):
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):
@@ -229,11 +278,23 @@ DNS.1 = {uuid}
run(['openssl', 'req', '-new', '-key', str(paths['key']),
'-out', str(paths['csr']), '-subj', subject])
run(['openssl', 'x509', '-req', '-in', str(paths['csr']),
'-CA', str(ac14k_cert), '-CAkey', str(ac14k_key),
'-CAcreateserial', '-CAserial', str(paths['srl']),
'-out', str(paths['leaf']), '-days', '3650',
'-extfile', str(paths['ext']), '-sha1'])
sign_cmd = ['openssl', 'x509', '-req', '-in', str(paths['csr']),
'-CA', str(ac14k_cert), '-CAkey', str(ac14k_key),
'-CAcreateserial', '-CAserial', str(paths['srl']),
'-out', str(paths['leaf']), '-days', '3650',
'-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()]
for p in chain_files:
@@ -459,7 +520,20 @@ def main():
print("=" * 60)
print(f"Phase 3: mint client cert with UUID {uuid}")
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" leaf: {paths['leaf']}")
print(f" fullchain: {paths['fullchain']}")
+111
View File
@@ -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"],
)
+101
View File
@@ -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)
+158
View File
@@ -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")
]
+75
View File
@@ -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")
+128
View File
@@ -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())
+308
View File
@@ -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())