From 4781f4eaa7c7f84e37cad4d35f4b8d4e838d7272 Mon Sep 17 00:00:00 2001 From: DaC Date: Thu, 8 Oct 2026 21:37:10 +0200 Subject: [PATCH 1/5] docs: curate accurate discovery topics for four public labs --- repository-discovery.json | 71 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 repository-discovery.json diff --git a/repository-discovery.json b/repository-discovery.json new file mode 100644 index 0000000..f31624e --- /dev/null +++ b/repository-discovery.json @@ -0,0 +1,71 @@ +{ + "organization": "Northguard-Security", + "repositories": [ + { + "name": "PAW--Phishing-Attribution-Workbench--", + "description": "Email-forensics workbench for phishing analysis, evidence preservation, infrastructure correlation and structured case reporting.", + "topics": [ + "phishing", + "email-forensics", + "email-security", + "digital-forensics", + "threat-intelligence", + "incident-response", + "infrastructure-analysis", + "osint", + "python", + "cybersecurity", + "security-research" + ] + }, + { + "name": "Reverse-Observer", + "description": "Network-path and middlebox measurement experiments using controlled packet probes, protocol comparisons and recorded hypotheses.", + "topics": [ + "network-security", + "network-measurement", + "protocol-analysis", + "packet-analysis", + "middlebox", + "deep-packet-inspection", + "tcp", + "scapy", + "python", + "security-research", + "network-analysis" + ] + }, + { + "name": "mirage-flow", + "description": "Experimental packet-timing watermark detection, noise simulation and stateful response modeling for network research.", + "topics": [ + "traffic-analysis", + "packet-timing", + "traffic-watermarking", + "network-monitoring", + "signal-processing", + "network-security", + "scapy", + "numpy", + "python", + "security-research" + ] + }, + { + "name": "Chimera", + "description": "Clipboard-to-DOM-XSS lab with vulnerable and safe CRM fixtures for controlled browser-security experiments.", + "topics": [ + "xss", + "dom-xss", + "cross-site-scripting", + "web-security", + "browser-security", + "clipboard-security", + "javascript", + "security-lab", + "security-research", + "cybersecurity" + ] + } + ] +} From 8090ddd6ae735b1b8de955d51902eb22a1f496c1 Mon Sep 17 00:00:00 2001 From: DaC Date: Thu, 8 Oct 2026 21:37:12 +0200 Subject: [PATCH 2/5] chore: add guarded manual GitHub topic sync tool --- tools/sync_repository_discovery.py | 110 +++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tools/sync_repository_discovery.py diff --git a/tools/sync_repository_discovery.py b/tools/sync_repository_discovery.py new file mode 100644 index 0000000..210f7ac --- /dev/null +++ b/tools/sync_repository_discovery.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Preview or apply public GitHub topics and empty descriptions via the GitHub CLI. + +Runs only on explicitly catalogued, public repositories. Existing topics are +preserved; nonempty descriptions are never overwritten. +""" +import argparse +import json +from pathlib import Path +import re +import subprocess +import sys + +CATALOG = Path(__file__).resolve().parents[1] / "repository-discovery.json" +TOPIC_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$") +REPO_RE = re.compile(r"^[A-Za-z0-9._-]+$") + + +def load_catalog(path=CATALOG): + data = json.loads(Path(path).read_text(encoding="utf-8")) + org = data.get("organization") + if not isinstance(org, str) or not REPO_RE.fullmatch(org): + raise ValueError("Invalid organization") + repos = data.get("repositories") + if not isinstance(repos, list) or not repos: + raise ValueError("No repositories in catalog") + seen = set() + for entry in repos: + name, topics, desc = entry.get("name"), entry.get("topics"), entry.get("description") + if not isinstance(name, str) or not REPO_RE.fullmatch(name) or name in seen: + raise ValueError(f"Invalid or duplicate repository: {name!r}") + seen.add(name) + if not isinstance(desc, str) or not 8 <= len(desc) <= 160: + raise ValueError(f"Invalid description length: {name}") + if not isinstance(topics, list) or not 1 <= len(topics) <= 20: + raise ValueError(f"Invalid topic count: {name}") + if len(topics) != len(set(topics)) or any( + not isinstance(t, str) or not TOPIC_RE.fullmatch(t) for t in topics + ): + raise ValueError(f"Invalid or duplicate topics: {name}") + return data + + +def gh_api(method, endpoint, payload=None): + args = ["gh", "api", "-X", method, endpoint, "-H", "Accept: application/vnd.github+json"] + if payload is not None: + args.extend(["--input", "-"]) + completed = subprocess.run( + args, + input=json.dumps(payload) if payload is not None else None, + text=True, + capture_output=True, + check=True, + ) + return json.loads(completed.stdout) if completed.stdout.strip() else {} + + +def sync_catalog(data, apply=False, only=None, include_descriptions=True): + org = data["organization"] + if only: + unknown = set(only) - {r["name"] for r in data["repositories"]} + if unknown: + raise ValueError(f"Unknown repository selection: {', '.join(sorted(unknown))}") + + change_count = 0 + for item in data["repositories"]: + name = item["name"] + if only and name not in only: + continue + endpoint = f"repos/{org}/{name}" + remote = gh_api("GET", endpoint) + if remote.get("full_name") != f"{org}/{name}" or remote.get("private") is not False: + raise ValueError(f"Repository is not the expected PUBLIC repository: {endpoint}") + current_topics = remote.get("topics", []) + if not isinstance(current_topics, list): + raise ValueError(f"Topic metadata missing: {endpoint}") + combined = list(dict.fromkeys(current_topics + item["topics"])) + add = [t for t in combined if t not in current_topics] + set_description = include_descriptions and not remote.get("description") + print(f"{endpoint}: +{len(add)} topics, description={'set' if set_description else 'unchanged'}") + if add: + print(" add: " + ", ".join(add)) + if apply: + if add: + gh_api("PUT", endpoint + "/topics", {"names": combined}) + if set_description: + gh_api("PATCH", endpoint, {"description": item["description"]}) + change_count += bool(add or set_description) + return change_count + + +def main(): + parser = argparse.ArgumentParser(description="Sync public Northguard repository discovery metadata") + parser.add_argument("--apply", action="store_true", help="Write topics and missing descriptions; default is preview only") + parser.add_argument("--repo", action="append", help="Only process named catalogued repository (repeatable)") + parser.add_argument("--topics-only", action="store_true", help="Do not fill missing descriptions") + parser.add_argument("--catalog", type=Path, default=CATALOG) + args = parser.parse_args() + try: + data = load_catalog(args.catalog) + count = sync_catalog(data, apply=args.apply, only=args.repo, + include_descriptions=not args.topics_only) + print(f"{'APPLIED' if args.apply else 'PREVIEW'}: {count} repositories need(ed) changes") + except (OSError, ValueError, subprocess.CalledProcessError) as exc: + print(f"METADATA_SYNC_ERROR: {exc}", file=sys.stderr) + raise SystemExit(1) from exc + + +if __name__ == "__main__": + main() From c0bb18e37d55b1e91e90f56b04c26f9d67513eaf Mon Sep 17 00:00:00 2001 From: DaC Date: Thu, 8 Oct 2026 21:37:15 +0200 Subject: [PATCH 3/5] docs: explain manual topic sync and metadata boundaries --- DISCOVERY.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 DISCOVERY.md diff --git a/DISCOVERY.md b/DISCOVERY.md new file mode 100644 index 0000000..0779b85 --- /dev/null +++ b/DISCOVERY.md @@ -0,0 +1,44 @@ +# Northguard public-repository discovery metadata + +The organization publishes four software research repositories. Their public +discovery metadata is versioned in [`repository-discovery.json`](repository-discovery.json). + +The catalog covers **public repositories only**. Do not put private project +names, unpublished repository inventories or unreleased code here. + +## Apply through GitHub CLI + +With `gh` installed and authenticated as a user allowed to edit these repos: + +```bash +gh auth status +python3 tools/sync_repository_discovery.py +python3 tools/sync_repository_discovery.py --apply +``` + +On Windows use `py` (or `python`) instead of `python3`. + +The first invocation is **read-only**, displaying the changes. The second adds +the catalogued topics and fills **only empty** repository descriptions. Existing +topics and nonempty descriptions are preserved. To alter topics on only one +repository, pass `--repo `; to omit description updates, pass +`--topics-only`. + +The script authenticates via GitHub CLI and never reads or stores tokens itself. +It refuses to write to a repository that is not public or whose canonical name +differs from the manifest. Changes to GitHub metadata **do not** require a code +commit in the target software repositories. + +**CI scope:** unit tests validate the catalog and the requests the script would +send. GitHub Actions does *not* update any repository topics automatically; the +manual `--apply` command is required. + +## Editorial approach + +Tags name implemented mechanisms, actual research domains, and common discovery +terms. They do not assert production readiness, verified human attribution, +general network evasion, or validated endpoint protection. + +The catalog is tied to the current organization slug. If the organization is +renamed during a later rebrand, update the slug in this file before running +the command again. From fc60ebeb694a71b039f4961a4e64a592444c1960 Mon Sep 17 00:00:00 2001 From: DaC Date: Thu, 8 Oct 2026 21:37:46 +0200 Subject: [PATCH 4/5] test: validate public topic manifest and simulate guarded updates --- tests/test_repository_discovery.py | 94 ++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/test_repository_discovery.py diff --git a/tests/test_repository_discovery.py b/tests/test_repository_discovery.py new file mode 100644 index 0000000..fb4575f --- /dev/null +++ b/tests/test_repository_discovery.py @@ -0,0 +1,94 @@ +"""Static and mocked checks: no GitHub metadata is changed in CI.""" +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from tools.sync_repository_discovery import load_catalog, sync_catalog + + +class CatalogTests(unittest.TestCase): + def test_only_four_known_public_labs_are_catalogued(self): + data = load_catalog() + self.assertEqual(data["organization"], "Northguard-Security") + names = {r["name"] for r in data["repositories"]} + self.assertEqual(names, { + "PAW--Phishing-Attribution-Workbench--", + "Reverse-Observer", + "mirage-flow", + "Chimera", + }) + + def test_invalid_topic_or_duplicate_rejected(self): + source = load_catalog() + source["repositories"][0]["topics"].append("Bad Topic") + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "bad.json" + path.write_text(json.dumps(source), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "Invalid or duplicate topics"): + load_catalog(path) + + def test_preview_does_not_mutate(self): + data = load_catalog() + one = data["repositories"][0] + def fake_api(method, endpoint, payload=None): + self.assertEqual(method, "GET") + self.assertEqual(endpoint, f"repos/Northguard-Security/{one['name']}") + return { + "full_name": f"Northguard-Security/{one['name']}", + "private": False, + "topics": [], + "description": None, + } + with patch("tools.sync_repository_discovery.gh_api", side_effect=fake_api) as api: + changed = sync_catalog(data, apply=False, only=[one["name"]]) + self.assertEqual(changed, 1) + self.assertEqual(api.call_count, 1) + + def test_apply_is_additive_and_does_not_overwrite_descriptions(self): + data = load_catalog() + one = data["repositories"][0] + calls = [] + def fake_api(method, endpoint, payload=None): + calls.append((method, endpoint, payload)) + if method == "GET": + return { + "full_name": f"Northguard-Security/{one['name']}", + "private": False, + "topics": ["custom-topic", one["topics"][0]], + "description": "Existing author description", + } + return {} + with patch("tools.sync_repository_discovery.gh_api", side_effect=fake_api): + changed = sync_catalog(data, apply=True, only=[one["name"]]) + self.assertEqual(changed, 1) + self.assertEqual([x[0] for x in calls], ["GET", "PUT"]) + names = calls[1][2]["names"] + self.assertEqual(names[0], "custom-topic") + self.assertEqual(names.count(one["topics"][0]), 1) + self.assertTrue(set(one["topics"]).issubset(set(names))) + + def test_apply_refuses_private_repository(self): + data = load_catalog() + one = data["repositories"][0] + with patch("tools.sync_repository_discovery.gh_api", return_value={ + "full_name": f"Northguard-Security/{one['name']}", + "private": True, + "topics": [], + "description": "", + }) as api: + with self.assertRaisesRegex(ValueError, "not the expected PUBLIC repository"): + sync_catalog(data, apply=True, only=[one["name"]]) + self.assertEqual(api.call_count, 1) + + def test_non_catalogued_repository_cannot_be_targeted(self): + data = load_catalog() + with patch("tools.sync_repository_discovery.gh_api") as api: + with self.assertRaisesRegex(ValueError, "Unknown repository"): + sync_catalog(data, apply=True, only=["nonexistent"]) + api.assert_not_called() + + +if __name__ == "__main__": + unittest.main() From 27350cca0b80763b3ec9af6d9aacc1384d54cffe Mon Sep 17 00:00:00 2001 From: DaC Date: Thu, 8 Oct 2026 21:37:48 +0200 Subject: [PATCH 5/5] ci: require manifest integrity before public metadata sync --- .github/workflows/repository-discovery.yml | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/repository-discovery.yml diff --git a/.github/workflows/repository-discovery.yml b/.github/workflows/repository-discovery.yml new file mode 100644 index 0000000..58fba14 --- /dev/null +++ b/.github/workflows/repository-discovery.yml @@ -0,0 +1,35 @@ +name: Repository metadata catalog integrity + +on: + pull_request: + paths: + - repository-discovery.json + - DISCOVERY.md + - tools/sync_repository_discovery.py + - tests/test_repository_discovery.py + - .github/workflows/repository-discovery.yml + push: + branches: [main] + paths: + - repository-discovery.json + - DISCOVERY.md + - tools/sync_repository_discovery.py + - tests/test_repository_discovery.py + - .github/workflows/repository-discovery.yml + +permissions: + contents: read + +jobs: + catalog-check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Compile metadata tooling + run: python -m py_compile tools/sync_repository_discovery.py tests/test_repository_discovery.py + - name: Validate catalog and simulated GitHub writes + run: python -m unittest discover -s tests -p 'test_repository_discovery.py' -v