Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
6927c17
Accept the decision on signing in without a keychain
d0ubl3g Oct 11, 2026
960eeaa
Add an opt-in plain-text token store
d0ubl3g Oct 11, 2026
15c481f
Document the plain-text token store
d0ubl3g Oct 11, 2026
8a93e20
Clarify where the token files are and when commands refuse them
d0ubl3g Oct 11, 2026
e981c73
Harden the plain-text token store
d0ubl3g Oct 11, 2026
0c33d49
Point the macOS test at the sessions directory outside the configuration
d0ubl3g Oct 11, 2026
868c0a4
State how long a copied token file stays useful
d0ubl3g Oct 11, 2026
82ad624
Build the symlinked-directory test next to the sessions directory
d0ubl3g Oct 11, 2026
7edc12a
Compile the network file system table on Unix only
d0ubl3g Oct 11, 2026
2d81ca6
Merge branch 'main' into dev
d0ubl3g Oct 15, 2026
fa586f2
Merge remote-tracking branch 'origin/dev' into feat/plaintext-file-store
d0ubl3g Oct 15, 2026
305520d
State the 90-day session limit now that the service enforces it
d0ubl3g Oct 15, 2026
956b8c3
Do not print the uid when the owner test fails
d0ubl3g Oct 15, 2026
310385f
Do not print the owner's uid when the owner test fails
d0ubl3g Oct 15, 2026
bd383d9
Follow version 2026-10-19 of the API document
d0ubl3g Oct 15, 2026
71d1962
Install with Homebrew by the formula's full name
d0ubl3g Oct 15, 2026
67c4370
Merge pull request #119 from Hodeitek/feat/plaintext-file-store
d0ubl3g Oct 15, 2026
f276095
Merge remote-tracking branch 'origin/dev' into docs/homebrew-tap-trust
d0ubl3g Oct 15, 2026
0c67c0c
Show the credential's tenant in whoami
d0ubl3g Oct 15, 2026
6bc74eb
Clean the API's text in the whoami warning
d0ubl3g Oct 15, 2026
f1a4447
Merge pull request #180 from Hodeitek/docs/homebrew-tap-trust
d0ubl3g Oct 15, 2026
875547b
Merge remote-tracking branch 'origin/dev' into feat/spec-2026-10-19
d0ubl3g Oct 15, 2026
57a466d
Merge pull request #178 from Hodeitek/feat/spec-2026-10-19
d0ubl3g Oct 15, 2026
5aed81a
Merge remote-tracking branch 'origin/dev' into feat/whoami-me
d0ubl3g Oct 15, 2026
a7af2c1
Add the reports commands: list, get and download
d0ubl3g Oct 15, 2026
7da034c
Merge branch 'feat/whoami-me' into feat/reports
d0ubl3g Oct 15, 2026
ace58fd
Merge pull request #181 from Hodeitek/feat/whoami-me
d0ubl3g Oct 15, 2026
f2fda5f
Harden saving a downloaded report
d0ubl3g Oct 15, 2026
60700b2
Merge remote-tracking branch 'origin/dev' into feat/reports
d0ubl3g Oct 15, 2026
3a54e37
Merge pull request #184 from Hodeitek/feat/reports
d0ubl3g Oct 15, 2026
454ea3c
Explain how to open a blocked macOS binary
d0ubl3g Oct 15, 2026
86e430a
Publish a security.txt and check its expiry
d0ubl3g Oct 15, 2026
2dc3086
Merge pull request #185 from Hodeitek/docs/macos-gatekeeper
d0ubl3g Oct 15, 2026
2d0512c
Keep the refused-connection test from racing other tests
d0ubl3g Oct 15, 2026
48e3604
Merge pull request #186 from Hodeitek/feat/security-txt
d0ubl3g Oct 15, 2026
e168f45
Merge pull request #187 from Hodeitek/fix/flaky-connection-retry-test
d0ubl3g Oct 15, 2026
52bfc29
Prepare Developer ID signing and notarization for macOS
d0ubl3g Oct 15, 2026
f5dfb22
Show the signed-in user in whoami from /v1/me
d0ubl3g Oct 15, 2026
7bc2d7f
Merge pull request #189 from Hodeitek/feat/whoami-user
d0ubl3g Oct 15, 2026
fa8cce2
Merge pull request #188 from Hodeitek/feat/macos-signing-prep
d0ubl3g Oct 15, 2026
a49b89e
Make the macOS signing check optional in the release-signing steps
d0ubl3g Oct 15, 2026
6369524
Merge pull request #190 from Hodeitek/docs/macos-enable-steps
d0ubl3g Oct 15, 2026
d651737
Prepare 0.5.0: version and changelog
d0ubl3g Oct 15, 2026
17627a1
Merge pull request #191 from Hodeitek/release/0.5.0-prep
d0ubl3g Oct 15, 2026
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
Binary file not shown.
119 changes: 119 additions & 0 deletions .github/scripts/security-txt-check-test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Hodeitek S.L.
"""Tests of security-txt-check.py, including the published .well-known/security.txt.

Usage: python3 -I .github/scripts/security-txt-check-test.py

Tests whose names contain "negative" feed changed data on purpose; they run in CI.
"""

import datetime
import importlib.util
import pathlib
import unittest

HERE = pathlib.Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location("security_txt_check", HERE / "security-txt-check.py")
sc = importlib.util.module_from_spec(spec)
spec.loader.exec_module(sc)

NOW = datetime.datetime(2026, 10, 15, tzinfo=datetime.timezone.utc)
GOOD = """# A comment
Contact: mailto:security@example.com
Contact: https://example.com/report
Expires: 2027-09-15T00:00:00.000Z
Preferred-Languages: en, es
Policy: https://example.com/policy
Canonical: https://example.com/.well-known/security.txt
"""


def without(prefix):
return "".join(line for line in GOOD.splitlines(True) if not line.startswith(prefix))


def problems(text, now=NOW):
return sc.check(text, now)[0]


class SecurityTxtCheck(unittest.TestCase):
def test_good_file_passes(self):
found, days = sc.check(GOOD, NOW)
self.assertEqual(found, [])
self.assertEqual(days, 335)

def test_published_file_is_valid(self):
# Checked as of 90 days before it expires, so this test does not turn red when the expiry
# approaches; the weekly check in security-txt.yml does that.
text = (HERE.parent.parent / ".well-known" / "security.txt").read_text(encoding="utf-8")
fields, _ = sc.parse(text)
as_of = sc.parse_expires(fields["expires"][0]) - datetime.timedelta(days=90)
self.assertEqual(problems(text, as_of), [])

def test_published_file_names_this_repository(self):
text = (HERE.parent.parent / ".well-known" / "security.txt").read_text(encoding="utf-8")
fields, _ = sc.parse(text)
self.assertIn("mailto:security@hodeitek.com", fields["contact"])
self.assertTrue(fields["policy"][0].endswith("/Hodeitek/hodeishield-cli/blob/main/SECURITY.md"))

def test_negative_missing_contact(self):
self.assertIn("Contact is missing", problems(without("Contact:")))

def test_negative_missing_expires(self):
self.assertIn("Expires is missing", problems(without("Expires:")))

def test_negative_expired(self):
later = datetime.datetime(2027, 9, 16, tzinfo=datetime.timezone.utc)
self.assertTrue(any("expired" in p for p in problems(GOOD, later)))

def test_negative_expiring_soon(self):
soon = datetime.datetime(2027, 7, 20, tzinfo=datetime.timezone.utc)
self.assertTrue(any("renew it" in p for p in problems(GOOD, soon)))

def test_threshold_is_sixty_days(self):
edge = datetime.datetime(2027, 7, 17, tzinfo=datetime.timezone.utc) # exactly 60 days left
self.assertEqual(problems(GOOD, edge), [])
before = datetime.datetime(2027, 7, 18, tzinfo=datetime.timezone.utc) # 59 days left
self.assertTrue(any("renew it" in p for p in problems(GOOD, before)))

def test_negative_too_far_ahead(self):
text = GOOD.replace("2027-09-15", "2028-01-01")
self.assertTrue(any("more than a year" in p for p in problems(text)))

def test_negative_expires_without_time_zone(self):
text = GOOD.replace("2027-09-15T00:00:00.000Z", "2027-09-15T00:00:00")
self.assertTrue(any("time zone" in p for p in problems(text)))

def test_negative_expires_not_a_date(self):
text = GOOD.replace("2027-09-15T00:00:00.000Z", "next year")
self.assertTrue(any("Expires is not" in p for p in problems(text)))

def test_negative_expires_twice(self):
self.assertIn("expires must appear only once", problems(GOOD + "Expires: 2027-09-15T00:00:00Z\n"))

def test_negative_plain_http_urls(self):
self.assertTrue(any("policy" in p for p in problems(GOOD.replace("https://example.com/policy", "http://example.com/policy"))))
text = GOOD.replace("https://example.com/.well-known", "http://example.com/.well-known")
self.assertTrue(any("canonical" in p for p in problems(text)))

def test_negative_bad_contact(self):
self.assertTrue(any("Contact must be" in p for p in problems(GOOD + "Contact: security@example.com\n")))
self.assertTrue(any("Contact must be" in p for p in problems(GOOD + "Contact: http://example.com\n")))

def test_negative_bad_language(self):
text = GOOD.replace("en, es", "english; spanish")
self.assertTrue(any("Preferred-Languages" in p for p in problems(text)))

def test_negative_not_a_field(self):
self.assertTrue(any("not a field" in p for p in problems(GOOD + "this is not a field\n")))

def test_negative_unknown_field(self):
self.assertIn("unknown field secret", problems(GOOD + "Secret: x\n"))

def test_extension_fields_are_allowed(self):
self.assertEqual(problems(GOOD + "X-Note: ok\n"), [])


if __name__ == "__main__":
unittest.main()
130 changes: 130 additions & 0 deletions .github/scripts/security-txt-check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Hodeitek S.L.
"""Checks .well-known/security.txt against RFC 9116 and reminds the maintainers to renew it.

Usage: python3 -I .github/scripts/security-txt-check.py [FILE] [--warn-days N] [--today YYYY-MM-DD]

Fails (exit 1) when the file breaks a requirement of RFC 9116 that matters here, when it has
expired, or when fewer than --warn-days (default 60) days remain before its Expires date. Prints
one line per problem and, when all is well, how many days remain. Reads the file only.
"""

import argparse
import datetime
import re
import sys

DEFAULT_FILE = ".well-known/security.txt"
DEFAULT_WARN_DAYS = 60
# Fields that may appear once (RFC 9116, section 2.5).
SINGLE = {"expires", "preferred-languages", "canonical", "policy", "hiring", "csaf"}
KNOWN = SINGLE | {"contact", "encryption", "acknowledgments"}
# RFC 5646 language tags, simplified: letters, then optional subtags.
LANGUAGE = re.compile(r"^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$")


def parse(text):
"""Returns (fields, problems): fields maps a lower-case name to its list of values."""
fields, problems = {}, []
for number, line in enumerate(text.splitlines(), 1):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith("-----BEGIN PGP"):
problems.append(f"line {number}: a signed file is not expected here")
continue
name, sep, value = line.partition(":")
if not sep or not re.fullmatch(r"[A-Za-z][A-Za-z0-9-]*", name):
problems.append(f"line {number}: not a field or a comment")
continue
fields.setdefault(name.lower(), []).append(value.strip())
return fields, problems


def parse_expires(value):
"""An ISO 8601 date and time with a time zone, as RFC 9116 requires. Raises ValueError."""
if not re.search(r"(Z|[+-]\d{2}:\d{2})$", value):
raise ValueError("no time zone")
when = datetime.datetime.fromisoformat(value)
if when.tzinfo is None:
raise ValueError("no time zone")
return when


def check(text, now, warn_days=DEFAULT_WARN_DAYS):
"""Returns (problems, days_left). days_left is None when Expires cannot be read."""
fields, problems = parse(text)
for name in sorted(fields):
if name not in KNOWN and not name.startswith("x-"):
problems.append(f"unknown field {name}")
if name in SINGLE and len(fields[name]) > 1:
problems.append(f"{name} must appear only once")

contacts = fields.get("contact", [])
if not contacts:
problems.append("Contact is missing")
for value in contacts:
if not re.match(r"(https://\S+|mailto:[^@\s]+@[^@\s]+|tel:\+?[0-9 .()-]+)$", value):
problems.append(f"Contact must be an https:, mailto: or tel: URI: {value!r}")

for name in ("policy", "canonical", "encryption", "acknowledgments", "hiring", "csaf"):
for value in fields.get(name, []):
if not re.match(r"https://\S+$", value):
problems.append(f"{name} must be an https: URL: {value!r}")

for value in fields.get("preferred-languages", []):
tags = [tag.strip() for tag in value.split(",")]
if not all(LANGUAGE.match(tag) for tag in tags):
problems.append(f"Preferred-Languages is not a list of language tags: {value!r}")

days_left = None
expires = fields.get("expires", [])
if not expires:
problems.append("Expires is missing")
elif len(expires) == 1:
try:
when = parse_expires(expires[0])
except ValueError as error:
problems.append(f"Expires is not an ISO 8601 date and time with a time zone ({error}): {expires[0]!r}")
else:
delta = when - now
days_left = delta.days
if delta <= datetime.timedelta(0):
problems.append(f"the file expired on {expires[0]}")
elif delta > datetime.timedelta(days=366):
problems.append("Expires is more than a year ahead; RFC 9116 recommends less than a year")
elif delta < datetime.timedelta(days=warn_days):
problems.append(
f"the file expires on {expires[0]}, in {days_left} days (less than {warn_days}); renew it"
)
return problems, days_left


def main(argv):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("file", nargs="?", default=DEFAULT_FILE)
parser.add_argument("--warn-days", type=int, default=DEFAULT_WARN_DAYS)
parser.add_argument("--today", help="UTC date to check against, for tests (default: now)")
args = parser.parse_args(argv)
if args.today:
now = datetime.datetime.fromisoformat(args.today).replace(tzinfo=datetime.timezone.utc)
else:
now = datetime.datetime.now(datetime.timezone.utc)
try:
with open(args.file, encoding="utf-8") as handle:
text = handle.read()
except OSError as error:
print(f"{args.file}: cannot be read: {error}")
return 1
problems, days_left = check(text, now, args.warn_days)
for problem in problems:
print(f"{args.file}: {problem}")
if problems:
return 1
print(f"{args.file}: valid, expires in {days_left} days")
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
Loading
Loading