feat: support OSPS Baseline schema in CII criteria script
This commit is contained in:
+282
-75
@@ -2,25 +2,39 @@
|
||||
"""List incomplete OpenSSF Best Practices criteria and generate a pre-filled edit URL.
|
||||
|
||||
Usage:
|
||||
cii_criteria.py <project_id> [section] [--exclude n1,n2,n3] [--na n1,n2,n3]
|
||||
cii_criteria.py <project_id> [section]
|
||||
[--schema {osps,metal}] [--exclude n1,n2,n3]
|
||||
[--na n1,n2,n3] [--no-open] [--open]
|
||||
|
||||
section: passing | silver | gold | baseline-1 | baseline-2 | baseline-3
|
||||
(default: passing)
|
||||
--exclude: comma-separated criterion names to SKIP from the pre-filled URL
|
||||
(they stay at their current Unmet/?/N/A in the form).
|
||||
--na: comma-separated criterion names to mark as N/A in the pre-filled URL
|
||||
(emits <name>_status=N/A with the justification from the JSON).
|
||||
--no-open: never open the generated URL in a browser (useful for CI/piping).
|
||||
--open: force-open the URL even when stdout is not a TTY (e.g. when piped).
|
||||
project_id: bestpractices.dev project numeric id (default: 14227)
|
||||
section: passing | silver | gold | baseline-1 | baseline-2 | baseline-3
|
||||
(default: passing for --schema metal; baseline-1 for --schema osps)
|
||||
--schema: osps (DEFAULT) -> OSPS Baseline schema (baseline_criteria.yml;
|
||||
project JSON uses `OSPS-*` criterion ids)
|
||||
metal (legacy) -> "Metal" schema (criteria.yml; short criterion
|
||||
names like `static_analysis`). Keeps the old behavior.
|
||||
--exclude: comma-separated criterion ids to SKIP from the pre-filled URL
|
||||
(they stay at their current Unmet/?/N/A in the form).
|
||||
--na: comma-separated criterion ids to mark as N/A in the pre-filled URL
|
||||
(emits <id>_status=N/A with the justification from the JSON).
|
||||
--no-open: never open the generated URL in a browser (useful for CI/piping).
|
||||
--open: force-open the URL even when stdout is not a TTY (e.g. when piped).
|
||||
|
||||
When run interactively (stdout is a TTY) the script auto-opens the generated
|
||||
edit URL in the default browser. Piping to a file or another command prints the
|
||||
URL only (no browser spawn). Use --no-open to suppress auto-open, --open to
|
||||
force it regardless of TTY.
|
||||
|
||||
The script fetches the upstream criteria.yml to learn which criteria belong to
|
||||
which tier/section, then restricts BOTH the printed incomplete list AND the
|
||||
generated edit-URL parameters to only that section's criteria.
|
||||
SCHEMA DIFFERENCES
|
||||
------------------
|
||||
* metal — fetches criteria.yml, maps tiers via its nested structure, and
|
||||
pre-fills every Unmet/? criterion in the section as `Met` (placeholder
|
||||
justification when none is known). This is the legacy behavior.
|
||||
* osps — fetches baseline_criteria.yml, maps `original_id` -> tier, and only
|
||||
pre-fills a criterion as `Met` when a truthful justification exists in
|
||||
cii_justifications.json (keyed by the `OSPS-*` id). Criteria without a
|
||||
justification are left at their current Unmet/? state so the URL never claims
|
||||
more than is documented. `overrides=*,osps_*` is always appended.
|
||||
"""
|
||||
import sys, os, json, subprocess, urllib.request, urllib.parse, argparse
|
||||
import ssl
|
||||
@@ -32,11 +46,19 @@ except Exception:
|
||||
_SSL_CTX = ssl.create_default_context()
|
||||
|
||||
BASE = "https://www.bestpractices.dev"
|
||||
CRITERIA_URL = ("https://raw.githubusercontent.com/ossf/best-practices-badge/"
|
||||
"main/criteria/criteria.yml")
|
||||
|
||||
# CLI section argument -> tier key inside criteria.yml
|
||||
SECTION_TO_TIER = {
|
||||
# Legacy "Metal" schema definitions.
|
||||
METAL_CRITERIA_URL = ("https://raw.githubusercontent.com/ossf/best-practices-badge/"
|
||||
"main/criteria/criteria.yml")
|
||||
|
||||
# OSPS Baseline schema definitions.
|
||||
OSPS_CRITERIA_URL = ("https://raw.githubusercontent.com/ossf/best-practices-badge/"
|
||||
"main/criteria/baseline_criteria.yml")
|
||||
|
||||
OSPS_TIERS = ("baseline-1", "baseline-2", "baseline-3")
|
||||
|
||||
# CLI section argument -> tier key inside the legacy criteria.yml.
|
||||
METAL_SECTION_TO_TIER = {
|
||||
"passing": "0",
|
||||
"silver": "1",
|
||||
"gold": "2",
|
||||
@@ -45,11 +67,20 @@ SECTION_TO_TIER = {
|
||||
"baseline-3": "baseline-3",
|
||||
}
|
||||
|
||||
# Default section per schema (used only when no section argument is given).
|
||||
SCHEMA_DEFAULT_SECTION = {
|
||||
"metal": "passing",
|
||||
"osps": "baseline-1",
|
||||
}
|
||||
|
||||
# Criterion ids whose status is pre-fillable as "Met".
|
||||
DOABLE_STATUSES = ("Unmet", "?")
|
||||
|
||||
|
||||
def load_justifications():
|
||||
"""Load real justifications from the sibling cii_justifications.json.
|
||||
|
||||
Returns a dict name -> justification string. Falls back to {} if the file
|
||||
Returns a dict id -> justification string. Falls back to {} if the file
|
||||
is missing or invalid, so callers should still default to a placeholder.
|
||||
"""
|
||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
@@ -91,7 +122,25 @@ def open_url(url):
|
||||
f"Open this URL manually:\n{url}", file=sys.stderr)
|
||||
|
||||
|
||||
def _collect_names(node, out):
|
||||
def load_project_criteria(project_id):
|
||||
"""Fetch the project JSON and return {criterion_id: {status, justification}}."""
|
||||
data = json.loads(fetch(f"{BASE}/projects/{project_id}.json"))
|
||||
criteria = {}
|
||||
for k, v in data.items():
|
||||
if k.endswith("_status") and isinstance(v, str):
|
||||
name = k[: -len("_status")]
|
||||
criteria[name] = {
|
||||
"status": v,
|
||||
"justification": data.get(name + "_justification", "") or "",
|
||||
}
|
||||
return data, criteria
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Metal (legacy) schema #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _collect_metal_names(node, out):
|
||||
"""Recursively walk the criteria.yml nested structure.
|
||||
|
||||
The file is a list of (tier_key, [sections]); each section is
|
||||
@@ -104,19 +153,19 @@ def _collect_names(node, out):
|
||||
out.append(key) # leaf criterion
|
||||
elif isinstance(val, list):
|
||||
for child in val:
|
||||
_collect_names(child, out)
|
||||
_collect_metal_names(child, out)
|
||||
elif isinstance(node, list):
|
||||
for child in node:
|
||||
_collect_names(child, out)
|
||||
_collect_metal_names(child, out)
|
||||
|
||||
|
||||
def fetch_section_map():
|
||||
"""Return {section_arg: set(criterion_names)} for every known tier.
|
||||
def fetch_metal_section_map():
|
||||
"""Return {section_arg: set(criterion_names)} for every known Metal tier.
|
||||
|
||||
Returns None if criteria.yml could not be fetched/parsed.
|
||||
"""
|
||||
try:
|
||||
raw = fetch(CRITERIA_URL)
|
||||
raw = fetch(METAL_CRITERIA_URL)
|
||||
except Exception as e:
|
||||
print(f"WARNING: could not fetch criteria definitions ({e}); "
|
||||
f"falling back to all-criteria behavior.", file=sys.stderr)
|
||||
@@ -134,52 +183,19 @@ def fetch_section_map():
|
||||
if isinstance(item, tuple) and len(item) == 2:
|
||||
tier_key, sections = item
|
||||
names = []
|
||||
_collect_names(sections, names)
|
||||
_collect_metal_names(sections, names)
|
||||
tier_names[tier_key] = set(names)
|
||||
|
||||
section_map = {}
|
||||
for section_arg, tier_key in SECTION_TO_TIER.items():
|
||||
for section_arg, tier_key in METAL_SECTION_TO_TIER.items():
|
||||
section_map[section_arg] = tier_names.get(tier_key, set())
|
||||
return section_map
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="List incomplete CII/OpenSSF Best Practices criteria.")
|
||||
parser.add_argument("project_id", nargs="?", default="14227")
|
||||
parser.add_argument("section", nargs="?", default="passing",
|
||||
choices=list(SECTION_TO_TIER.keys()))
|
||||
parser.add_argument("--exclude", default="",
|
||||
help="Comma-separated criterion names to skip in the URL.")
|
||||
parser.add_argument("--na", default="",
|
||||
help="Comma-separated criterion names to mark as N/A "
|
||||
"with the justification from cii_justifications.json.")
|
||||
parser.add_argument("--no-open", action="store_true",
|
||||
help="Never open the generated URL in a browser "
|
||||
"(useful for CI/piping).")
|
||||
parser.add_argument("--open", action="store_true",
|
||||
help="Force-open the URL even when stdout is not a TTY.")
|
||||
args = parser.parse_args()
|
||||
def run_metal(project_id, section, exclude, na_set, justifications, args):
|
||||
data, criteria = load_project_criteria(project_id)
|
||||
|
||||
project_id = args.project_id
|
||||
section = args.section
|
||||
exclude = {x.strip() for x in args.exclude.split(",") if x.strip()}
|
||||
na_set = {x.strip() for x in args.na.split(",") if x.strip()}
|
||||
|
||||
justifications = load_justifications()
|
||||
|
||||
data = json.loads(fetch(f"{BASE}/projects/{project_id}.json"))
|
||||
|
||||
# All criteria reported by the project (every tier).
|
||||
criteria = {}
|
||||
for k, v in data.items():
|
||||
if k.endswith("_status") and isinstance(v, str):
|
||||
name = k[: -len("_status")]
|
||||
criteria[name] = {"status": v,
|
||||
"justification": data.get(name + "_justification", "") or ""}
|
||||
|
||||
# Build the section -> criterion-name set from upstream definitions.
|
||||
section_map = fetch_section_map()
|
||||
section_map = fetch_metal_section_map()
|
||||
target_names = section_map.get(section) if section_map else None
|
||||
if target_names is not None and not target_names:
|
||||
print(f"WARNING: no criteria found for section '{section}' in "
|
||||
@@ -187,22 +203,23 @@ def main():
|
||||
file=sys.stderr)
|
||||
target_names = None # fall back: no section restriction
|
||||
|
||||
# Restrict to the requested section when we have a valid name set.
|
||||
if target_names is not None:
|
||||
scoped = {n: c for n, c in criteria.items() if n in target_names}
|
||||
else:
|
||||
scoped = criteria
|
||||
|
||||
incomplete = {n: c for n, c in scoped.items() if c["status"] != "Met"}
|
||||
doable = {n: c for n, c in incomplete.items() if c["status"] in ("Unmet", "?")}
|
||||
doable = {n: c for n, c in incomplete.items() if c["status"] in DOABLE_STATUSES}
|
||||
na = {n: c for n, c in incomplete.items() if c["status"] == "N/A"}
|
||||
|
||||
print(f"Project {project_id}: badge_level={data.get('badge_level')} "
|
||||
f"(passing%={data.get('badge_percentage_0')} "
|
||||
f"silver%={data.get('badge_percentage_1')} "
|
||||
f"gold%={data.get('badge_percentage_2')})")
|
||||
scope_note = f"section '{section}'" if target_names is not None else "ALL sections (fallback)"
|
||||
print(f"Scope: {scope_note} | criteria in scope: {len(scoped)} | "
|
||||
scope_note = (f"section '{section}'" if target_names is not None
|
||||
else "ALL sections (fallback)")
|
||||
print(f"Schema: metal | Scope: {scope_note} | "
|
||||
f"criteria in scope: {len(scoped)} | "
|
||||
f"Incomplete: {len(incomplete)} "
|
||||
f"(Unmet/? : {len(doable)} | N/A: {len(na)})\n")
|
||||
|
||||
@@ -213,9 +230,6 @@ def main():
|
||||
for n, c in sorted(na.items()):
|
||||
print(f" - {n}: N/A | note: {c['justification'][:70]}")
|
||||
|
||||
# Build URL params, restricted to the section AND honoring --exclude /
|
||||
# --na. N/A criteria (already excluded from `doable`) are never pre-filled
|
||||
# as Met; --na criteria that ARE in `doable` are emitted as N/A instead.
|
||||
params = []
|
||||
for n in sorted(doable):
|
||||
if n in exclude:
|
||||
@@ -225,8 +239,6 @@ def main():
|
||||
params.append((f"{n}_status", "Met"))
|
||||
params.append((f"{n}_justification",
|
||||
justifications.get(n, "TODO: justify")))
|
||||
# N/A criteria: set status=N/A with a justification from the JSON, but only
|
||||
# when they belong to the targeted section (so the params stay in scope).
|
||||
for n in sorted(na_set):
|
||||
if n in exclude:
|
||||
continue
|
||||
@@ -236,16 +248,211 @@ def main():
|
||||
params.append((f"{n}_justification",
|
||||
justifications.get(n, "TODO: justify N/A")))
|
||||
params.append(("overrides", "*,osps_*"))
|
||||
edit_url = f"{BASE}/projects/{project_id}/{section}/edit?{urllib.parse.urlencode(params)}"
|
||||
edit_url = (f"{BASE}/projects/{project_id}/{section}/edit?"
|
||||
f"{urllib.parse.urlencode(params)}")
|
||||
print(f"\n=== Edit URL ({section}"
|
||||
+ (f", excluding {sorted(exclude)}" if exclude else "")
|
||||
+ (f", N/A {sorted(na_set)}" if na_set else "")
|
||||
+ f") ===\n{edit_url}")
|
||||
+ (f", excluding {sorted(exclude)}" if exclude else "")
|
||||
+ (f", N/A {sorted(na_set)}" if na_set else "")
|
||||
+ f") ===\n{edit_url}")
|
||||
print(f"\nURL param count for _status/_justification pairs: "
|
||||
f"{sum(1 for k,_ in params if k.endswith('_status'))}")
|
||||
f"{sum(1 for k, _ in params if k.endswith('_status'))}")
|
||||
print("WARNING: pre-fills Unmet/? criteria as 'Met' (and --na as 'N/A') using "
|
||||
"justifications from cii_justifications.json.")
|
||||
print("Review EVERY field before Save; only mark Met what you genuinely satisfy.")
|
||||
return edit_url
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# OSPS Baseline schema #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _collect_osps_controls(node, out):
|
||||
"""Recursively collect every control definition under a node.
|
||||
|
||||
A control is any dict containing an `original_id` key. We map that id to its
|
||||
{category, na_allowed, na_justification_required} so callers can scope by
|
||||
tier and validate N/A eligibility.
|
||||
"""
|
||||
if isinstance(node, dict):
|
||||
if "original_id" in node:
|
||||
out[node["original_id"]] = {
|
||||
"category": node.get("category"),
|
||||
"na_allowed": bool(node.get("na_allowed")),
|
||||
"na_justification_required": bool(
|
||||
node.get("na_justification_required")),
|
||||
}
|
||||
else:
|
||||
for v in node.values():
|
||||
_collect_osps_controls(v, out)
|
||||
elif isinstance(node, list):
|
||||
for v in node:
|
||||
_collect_osps_controls(v, out)
|
||||
|
||||
|
||||
def fetch_osps_tier_map():
|
||||
"""Return {tier: {original_id: control_meta}} from baseline_criteria.yml.
|
||||
|
||||
Returns {} if the file could not be fetched/parsed (caller falls back to
|
||||
all-criteria behavior).
|
||||
"""
|
||||
try:
|
||||
raw = fetch(OSPS_CRITERIA_URL)
|
||||
except Exception as e:
|
||||
print(f"WARNING: could not fetch OSPS criteria definitions ({e}); "
|
||||
f"falling back to all-criteria behavior.", file=sys.stderr)
|
||||
return {}
|
||||
try:
|
||||
doc = __import__("yaml").safe_load(raw)
|
||||
except Exception as e:
|
||||
print(f"WARNING: could not parse OSPS criteria definitions ({e}); "
|
||||
f"falling back to all-criteria behavior.", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
if not isinstance(doc, dict):
|
||||
return {}
|
||||
|
||||
tier_map = {}
|
||||
for tier in OSPS_TIERS:
|
||||
controls = {}
|
||||
if tier in doc:
|
||||
_collect_osps_controls(doc[tier], controls)
|
||||
tier_map[tier] = controls
|
||||
return tier_map
|
||||
|
||||
|
||||
def run_osps(project_id, section, exclude, na_set, justifications, args):
|
||||
data, criteria = load_project_criteria(project_id)
|
||||
|
||||
tier_map = fetch_osps_tier_map()
|
||||
controls = tier_map.get(section)
|
||||
if controls is None:
|
||||
print(f"WARNING: section '{section}' is not an OSPS Baseline tier; "
|
||||
f"falling back to all-criteria behavior.", file=sys.stderr)
|
||||
target_ids = None
|
||||
elif not controls:
|
||||
print(f"WARNING: no OSPS criteria found for tier '{section}'; "
|
||||
f"falling back to all-criteria behavior.", file=sys.stderr)
|
||||
target_ids = None
|
||||
else:
|
||||
target_ids = set(controls.keys())
|
||||
|
||||
if target_ids is not None:
|
||||
scoped = {n: c for n, c in criteria.items() if n in target_ids}
|
||||
else:
|
||||
scoped = criteria
|
||||
|
||||
incomplete = {n: c for n, c in scoped.items() if c["status"] != "Met"}
|
||||
doable = {n: c for n, c in incomplete.items() if c["status"] in DOABLE_STATUSES}
|
||||
na = {n: c for n, c in incomplete.items() if c["status"] == "N/A"}
|
||||
|
||||
print(f"Project {project_id}: badge_level={data.get('badge_level')}")
|
||||
scope_note = (f"tier '{section}'" if target_ids is not None
|
||||
else "ALL criteria (fallback)")
|
||||
print(f"Schema: osps | Scope: {scope_note} | "
|
||||
f"criteria in scope: {len(scoped)} | "
|
||||
f"Incomplete: {len(incomplete)} "
|
||||
f"(Unmet/? : {len(doable)} | N/A: {len(na)})\n")
|
||||
|
||||
print("Incomplete (Unmet/? — only those with a justification are pre-filled "
|
||||
"as Met):")
|
||||
for n, c in sorted(doable.items()):
|
||||
have = "J" if n in justifications else " "
|
||||
print(f" [{have}] {n}: {c['status']} | current note: "
|
||||
f"{c['justification'][:70]}")
|
||||
print("\nN/A (review separately — usually leave as N/A):")
|
||||
for n, c in sorted(na.items()):
|
||||
print(f" - {n}: N/A | note: {c['justification'][:70]}")
|
||||
|
||||
# Pre-fill Met ONLY for doable criteria that have a truthful justification
|
||||
# recorded locally. Criteria without one are left at their current state so
|
||||
# the URL never over-claims.
|
||||
params = []
|
||||
prefilled_met = []
|
||||
for n in sorted(doable):
|
||||
if n in exclude:
|
||||
continue
|
||||
if n in na_set:
|
||||
continue # handled below as N/A
|
||||
justification = justifications.get(n)
|
||||
if not justification:
|
||||
continue
|
||||
params.append((f"{n}_status", "Met"))
|
||||
params.append((f"{n}_justification", justification))
|
||||
prefilled_met.append(n)
|
||||
|
||||
# Pre-fill N/A for --na criteria that are in scope and allow N/A.
|
||||
for n in sorted(na_set):
|
||||
if n in exclude:
|
||||
continue
|
||||
if n not in scoped:
|
||||
continue
|
||||
ctrl = controls.get(n) if controls else None
|
||||
if ctrl is not None and not ctrl["na_allowed"]:
|
||||
print(f"WARNING: {n} does not allow N/A (na_allowed=false); "
|
||||
f"skipping --na for it.", file=sys.stderr)
|
||||
continue
|
||||
justification = justifications.get(
|
||||
n, criteria.get(n, {}).get("justification") or "TODO: justify N/A")
|
||||
params.append((f"{n}_status", "N/A"))
|
||||
params.append((f"{n}_justification", justification))
|
||||
|
||||
params.append(("overrides", "*,osps_*"))
|
||||
edit_url = (f"{BASE}/projects/{project_id}/{section}/edit?"
|
||||
f"{urllib.parse.urlencode(params)}")
|
||||
print(f"\n=== Edit URL ({section}"
|
||||
+ (f", excluding {sorted(exclude)}" if exclude else "")
|
||||
+ (f", N/A {sorted(na_set)}" if na_set else "")
|
||||
+ f") ===\n{edit_url}")
|
||||
print(f"\nPre-filled Met ({len(prefilled_met)}): {sorted(prefilled_met)}")
|
||||
print(f"URL param count for _status/_justification pairs: "
|
||||
f"{sum(1 for k, _ in params if k.endswith('_status'))}")
|
||||
print("WARNING: pre-fills only criteria with a recorded justification as "
|
||||
"'Met' (and --na as 'N/A').")
|
||||
print("Review EVERY field before Save; only mark Met what you genuinely satisfy.")
|
||||
return edit_url
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Entry point #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="List incomplete OpenSSF Best Practices (CII) criteria and "
|
||||
"generate a pre-filled edit URL. Defaults to the OSPS "
|
||||
"Baseline schema.")
|
||||
parser.add_argument("project_id", nargs="?", default="14227")
|
||||
parser.add_argument("section", nargs="?", default=None,
|
||||
choices=list(METAL_SECTION_TO_TIER.keys()))
|
||||
parser.add_argument("--schema", default="osps", choices=["osps", "metal"],
|
||||
help="Criteria schema: 'osps' (DEFAULT, OSPS Baseline) "
|
||||
"or 'metal' (legacy).")
|
||||
parser.add_argument("--exclude", default="",
|
||||
help="Comma-separated criterion ids to skip in the URL.")
|
||||
parser.add_argument("--na", default="",
|
||||
help="Comma-separated criterion ids to mark as N/A "
|
||||
"with the justification from cii_justifications.json.")
|
||||
parser.add_argument("--no-open", action="store_true",
|
||||
help="Never open the generated URL in a browser "
|
||||
"(useful for CI/piping).")
|
||||
parser.add_argument("--open", action="store_true",
|
||||
help="Force-open the URL even when stdout is not a TTY.")
|
||||
args = parser.parse_args()
|
||||
|
||||
project_id = args.project_id
|
||||
# Resolve the section: explicit arg wins; otherwise use the schema default.
|
||||
section = args.section or SCHEMA_DEFAULT_SECTION[args.schema]
|
||||
exclude = {x.strip() for x in args.exclude.split(",") if x.strip()}
|
||||
na_set = {x.strip() for x in args.na.split(",") if x.strip()}
|
||||
|
||||
justifications = load_justifications()
|
||||
|
||||
if args.schema == "metal":
|
||||
edit_url = run_metal(project_id, section, exclude, na_set,
|
||||
justifications, args)
|
||||
else:
|
||||
edit_url = run_osps(project_id, section, exclude, na_set,
|
||||
justifications, args)
|
||||
|
||||
# Auto-open the URL in a browser. Default: only when run interactively
|
||||
# (stdout is a TTY) and --no-open was not given. --open forces it even when
|
||||
|
||||
@@ -42,5 +42,8 @@
|
||||
"crypto_password_storage": "No passwords are stored by the application; the only secrets are a user-provided MT5/Wine path config (gitignored) and an optional release PAT — neither is a password persisted by the project.",
|
||||
"vulnerability_report_private": "No private vulnerability reporting channel is offered; reports are accepted via public GitHub Security Advisories/issues.",
|
||||
"vulnerability_report_response": "No formal fixed-response SLA; handled case-by-case via the public reporting channel.",
|
||||
"release_notes_vulns": "Release notes do not currently track security vulnerabilities separately."
|
||||
"release_notes_vulns": "Release notes do not currently track security vulnerabilities separately.",
|
||||
|
||||
"OSPS-AC-03.01": "The `master` primary branch is protected on GitHub: direct pushes are blocked and changes require a pull request with at least one approving review before merge. This enforcement mechanism prevents direct commits to the primary branch, satisfying OSPS-AC-03.01.",
|
||||
"OSPS-AC-03.02": "The `master` primary branch is protected against deletion on GitHub: branch protection disables branch deletion, and a separate deletion-protecting ruleset requires explicit confirmation of intent before any destructive action. This satisfies OSPS-AC-03.02."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user