Skip to content

Commit c80d11a

Browse files
authored
Add project foundation: governance, licensing, docs, and CI
Set up Open Detection Engineering for contributors: - README, licensing (CC BY-SA 4.0 / Apache 2.0), trademark policy, and citation metadata - Contributing guide with DCO, governance, maintainers, code of conduct, and security policy - Style guide and review process - Module, lesson, and metadata templates - Directory scaffold for core, tracks, labs, and datasets - CI for Markdown lint, links, module metadata, and citation validation, plus a check against committed ignored files Signed-off-by: Tallis Jordan <tj@tallisjordan.com>
1 parent 0c6e8e4 commit c80d11a

41 files changed

Lines changed: 2119 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.editorconfig‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# https://editorconfig.org
2+
root = true
3+
4+
[*]
5+
charset = utf-8
6+
end_of_line = lf
7+
insert_final_newline = true
8+
trim_trailing_whitespace = true
9+
indent_style = space
10+
indent_size = 2
11+
12+
[*.md]
13+
# Two trailing spaces are a hard line break in Markdown, so don't strip them.
14+
trim_trailing_whitespace = false
15+
16+
[*.py]
17+
indent_size = 4
18+
19+
[Makefile]
20+
indent_style = tab

‎.gitattributes‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Normalize line endings: LF in the repository and in working trees on every
2+
# OS, so Windows contributors don't create whole-file diffs.
3+
* text=auto eol=lf
4+
5+
# Binary files: never diff, never convert line endings.
6+
*.evtx binary
7+
*.pcap binary
8+
*.pcapng binary
9+
*.parquet binary
10+
*.png binary
11+
*.jpg binary
12+
*.jpeg binary
13+
*.gif binary
14+
*.pdf binary
15+
*.zip binary
16+
*.gz binary
17+
18+
# SVG is text, so it diffs and gets normalized like any other text file.
19+
*.svg text
20+
21+
# Large datasets: if files get big, track them with Git LFS instead. Uncomment
22+
# after running "git lfs install" and agreeing on a size threshold.
23+
# datasets/**/*.evtx filter=lfs diff=lfs merge=lfs -text
24+
# datasets/**/*.pcap filter=lfs diff=lfs merge=lfs -text
25+
# datasets/**/*.pcapng filter=lfs diff=lfs merge=lfs -text
26+
# datasets/**/*.parquet filter=lfs diff=lfs merge=lfs -text

‎.github/CODEOWNERS‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Default owner for everything. As maintainers and domain reviewers join,
2+
# add more specific paths below the default (the last matching pattern wins).
3+
* @mndtrcks
4+
5+
# Examples for later:
6+
# /core/ @maintainer-handle
7+
# /tracks/platforms/splunk/ @splunk-reviewer-handle

‎.github/ISSUE_TEMPLATE/config.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
blank_issues_enabled: false
2+
contact_links:
3+
- name: Questions and discussion
4+
url: https://github.com/ode-os/detection-engineering/discussions
5+
about: Ask questions, float ideas, or discuss the curriculum before opening a proposal.
6+
- name: Report a security issue or sensitive data
7+
url: https://github.com/ode-os/detection-engineering/security/advisories/new
8+
about: Report vulnerabilities or accidentally committed sensitive data privately. See SECURITY.md.

‎.github/PULL_REQUEST_TEMPLATE.md‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
## Summary
2+
3+
What does this PR change?
4+
5+
## Related Issue
6+
7+
Closes #
8+
9+
## Contribution Type
10+
11+
- [ ] Core curriculum
12+
- [ ] Domain track
13+
- [ ] Platform track
14+
- [ ] Advanced track
15+
- [ ] Lab
16+
- [ ] Dataset
17+
- [ ] Documentation
18+
- [ ] Governance / project maintenance
19+
20+
## Validation
21+
22+
- [ ] I understand and can defend the technical content in this contribution.
23+
- [ ] I verified technical claims, examples, and references.
24+
- [ ] This contribution is substantially my own work.
25+
- [ ] This contribution does not contain proprietary, confidential, or improperly licensed material.
26+
- [ ] I reviewed the relevant contribution guidelines.
27+
- [ ] All commits are signed off (`git commit -s`) per the [DCO](../CONTRIBUTING.md#developer-certificate-of-origin).
28+
29+
## Review Requested
30+
31+
What kind of review would be most useful?
32+
33+
- [ ] Technical accuracy
34+
- [ ] Operational realism
35+
- [ ] Curriculum structure
36+
- [ ] Instructional quality
37+
- [ ] Vendor neutrality
38+
- [ ] Lab / dataset validation
39+
- [ ] General review
40+
41+
## Affiliations / Conflicts
42+
43+
Disclose any relevant employer, vendor, training-provider, or other affiliation related to this contribution.
44+
45+
If none, write `None`.
46+
47+
## Additional Context
48+
49+
Anything else reviewers should know?
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Paths (one per line, exact match) that are deliberately tracked even though
2+
# .gitignore matches them, for example a reviewed binary under the malware
3+
# guard. Each entry needs a maintainer's approval in the PR that adds it.
4+
# Lines starting with # and blank lines are ignored.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#!/usr/bin/env bash
2+
# Fail if any tracked file matches a .gitignore rule (i.e. it was force-added).
3+
# This covers AI-tool files, secrets, binaries, and anything else .gitignore
4+
# keeps out. Deliberate exceptions go in .github/ignored-file-exceptions.txt.
5+
set -euo pipefail
6+
7+
exceptions=".github/ignored-file-exceptions.txt"
8+
9+
found="$(git ls-files -ci --exclude-standard)"
10+
11+
if [ -f "$exceptions" ]; then
12+
allowed="$(grep -vE '^[[:space:]]*(#|$)' "$exceptions" || true)"
13+
if [ -n "$allowed" ]; then
14+
found="$(grep -vxFf <(printf '%s\n' "$allowed") <<<"$found" || true)"
15+
fi
16+
fi
17+
18+
if [ -n "$found" ]; then
19+
while IFS= read -r file; do
20+
echo "::error file=${file}::Tracked file matches .gitignore. Remove it from the PR (git rm --cached), or add it to ${exceptions} if it is deliberate and reviewed."
21+
done <<<"$found"
22+
exit 1
23+
fi
24+
25+
echo "No tracked files match .gitignore."
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env python3
2+
"""Validate module-metadata.yml files against docs/REVIEW-PROCESS.md.
3+
4+
Errors fail the check. Review expiry only warns, so one stale module does not
5+
block unrelated pull requests.
6+
"""
7+
8+
import datetime
9+
import pathlib
10+
import re
11+
import sys
12+
13+
import yaml
14+
15+
STATUSES = {"draft", "in-review", "reviewed", "stable", "needs-update", "deprecated"}
16+
REVIEWED_STATUSES = {"reviewed", "stable"}
17+
STRING_FIELDS = ["title", "slug", "summary"]
18+
LIST_FIELDS = [
19+
"learning_objectives",
20+
"prerequisites",
21+
"authors",
22+
"reviewers",
23+
"maintainers",
24+
"related_tracks",
25+
"related_labs",
26+
"related_datasets",
27+
]
28+
REQUIRED = STRING_FIELDS + LIST_FIELDS + ["status", "version", "last_reviewed"]
29+
SLUG_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
30+
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
31+
EXPIRY_DAYS = 365
32+
33+
errors = 0
34+
35+
36+
def error(path, message):
37+
global errors
38+
errors += 1
39+
print(f"::error file={path}::{message}")
40+
41+
42+
def warning(path, message):
43+
print(f"::warning file={path}::{message}")
44+
45+
46+
def load(path):
47+
try:
48+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
49+
except yaml.YAMLError as exc:
50+
error(path, f"invalid YAML: {exc}")
51+
return None
52+
if not isinstance(data, dict):
53+
error(path, "top level must be a mapping")
54+
return None
55+
return data
56+
57+
58+
def handle(value):
59+
return str(value).strip().lstrip("@").lower()
60+
61+
62+
def parse_date(value):
63+
if isinstance(value, datetime.date):
64+
return value
65+
if isinstance(value, str) and DATE_RE.match(value):
66+
try:
67+
return datetime.date.fromisoformat(value)
68+
except ValueError:
69+
return None
70+
return None
71+
72+
73+
def check_template(path):
74+
data = load(path)
75+
if data is None:
76+
return
77+
for key in REQUIRED:
78+
if key not in data:
79+
error(path, f"template is missing field '{key}'")
80+
81+
82+
def check_module(path):
83+
data = load(path)
84+
if data is None:
85+
return
86+
87+
for key in REQUIRED:
88+
if key not in data:
89+
error(path, f"missing required field '{key}'")
90+
for key in data:
91+
if key not in REQUIRED:
92+
warning(path, f"unknown field '{key}'")
93+
94+
for key in STRING_FIELDS:
95+
value = data.get(key)
96+
if key in data and not (isinstance(value, str) and value.strip()):
97+
error(path, f"'{key}' must be a non-empty string")
98+
99+
for key in LIST_FIELDS:
100+
value = data.get(key)
101+
if key not in data:
102+
continue
103+
if not isinstance(value, list):
104+
error(path, f"'{key}' must be a list")
105+
continue
106+
for item in value:
107+
if not (isinstance(item, str) and item.strip()):
108+
error(path, f"'{key}' contains an empty entry (unfilled template?)")
109+
break
110+
111+
if isinstance(data.get("learning_objectives"), list) and not data["learning_objectives"]:
112+
error(path, "'learning_objectives' must have at least one entry")
113+
114+
slug = data.get("slug")
115+
if isinstance(slug, str) and slug.strip():
116+
if not SLUG_RE.match(slug):
117+
error(path, f"slug '{slug}' must be lowercase kebab-case")
118+
directory = re.sub(r"^\d+-", "", path.parent.name)
119+
if directory != slug:
120+
error(path, f"slug '{slug}' must match the directory name '{path.parent.name}' without its number prefix")
121+
122+
version = data.get("version")
123+
if "version" in data and (version is None or not str(version).strip()):
124+
error(path, "'version' must not be empty")
125+
126+
status = data.get("status")
127+
if "status" in data and status not in STATUSES:
128+
error(path, f"status '{status}' must be one of: {', '.join(sorted(STATUSES))}")
129+
130+
last_reviewed = data.get("last_reviewed")
131+
reviewed_on = None
132+
if last_reviewed is not None:
133+
reviewed_on = parse_date(last_reviewed)
134+
if reviewed_on is None:
135+
error(path, "'last_reviewed' must be null or a YYYY-MM-DD date")
136+
elif reviewed_on > datetime.date.today():
137+
error(path, "'last_reviewed' is in the future")
138+
139+
reviewers = data.get("reviewers") if isinstance(data.get("reviewers"), list) else []
140+
authors = data.get("authors") if isinstance(data.get("authors"), list) else []
141+
142+
overlap = {handle(r) for r in reviewers} & {handle(a) for a in authors}
143+
if overlap:
144+
error(path, f"reviewers must be independent of authors (both: {', '.join(sorted(overlap))})")
145+
146+
if status in REVIEWED_STATUSES:
147+
if not reviewers:
148+
error(path, f"status '{status}' requires at least one reviewer")
149+
if last_reviewed is None:
150+
error(path, f"status '{status}' requires 'last_reviewed'")
151+
elif reviewed_on is not None:
152+
age = (datetime.date.today() - reviewed_on).days
153+
if age > EXPIRY_DAYS:
154+
warning(path, f"last reviewed {age} days ago; past the {EXPIRY_DAYS}-day expiry, so the status should be 'needs-update'")
155+
156+
157+
def main():
158+
root = pathlib.Path(".")
159+
template = root / "templates" / "module-metadata.yml"
160+
if template.exists():
161+
check_template(template)
162+
163+
modules = sorted(
164+
p
165+
for p in root.rglob("module-metadata.yml")
166+
if ".git" not in p.parts and "templates" not in p.parts
167+
)
168+
for path in modules:
169+
check_module(path)
170+
171+
print(f"Checked {len(modules)} module(s); {errors} error(s).")
172+
return 1 if errors else 0
173+
174+
175+
if __name__ == "__main__":
176+
sys.exit(main())

‎.github/workflows/ci.yml‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches: [main]
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
markdown-lint:
13+
name: Markdown lint
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: 22
20+
- run: npx --yes markdownlint-cli2@0.23.3
21+
22+
local-links:
23+
name: Local links
24+
runs-on: ubuntu-latest
25+
steps:
26+
- uses: actions/checkout@v4
27+
- name: Check links between files in this repo
28+
uses: lycheeverse/lychee-action@v2
29+
with:
30+
lycheeVersion: v0.24.2
31+
args: --offline --no-progress --config .lychee.toml './**/*.md'
32+
fail: true
33+
34+
module-metadata:
35+
name: Module metadata
36+
runs-on: ubuntu-latest
37+
steps:
38+
- uses: actions/checkout@v4
39+
- uses: actions/setup-python@v5
40+
with:
41+
python-version: "3.12"
42+
- run: pip install pyyaml
43+
- run: python .github/scripts/validate_metadata.py
44+
45+
citation:
46+
name: Citation file
47+
runs-on: ubuntu-latest
48+
steps:
49+
- uses: actions/checkout@v4
50+
- uses: actions/setup-python@v5
51+
with:
52+
python-version: "3.12"
53+
- run: pip install cffconvert
54+
- run: cffconvert --validate
55+
56+
no-ignored-files:
57+
name: No ignored files committed
58+
runs-on: ubuntu-latest
59+
steps:
60+
- uses: actions/checkout@v4
61+
- run: bash .github/scripts/check_ignored_files.sh

0 commit comments

Comments
 (0)