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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/repository-discovery.yml
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions DISCOVERY.md
Original file line number Diff line number Diff line change
@@ -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 <name>`; 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.
71 changes: 71 additions & 0 deletions repository-discovery.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
]
}
94 changes: 94 additions & 0 deletions tests/test_repository_discovery.py
Original file line number Diff line number Diff line change
@@ -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()
110 changes: 110 additions & 0 deletions tools/sync_repository_discovery.py
Original file line number Diff line number Diff line change
@@ -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()
Loading