diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1c51ab3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + groups: + patch-updates: + update-types: + - patch + minor-major-updates: + update-types: + - minor + - major + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: + - "*" diff --git a/.github/deps-allowlist.yml b/.github/deps-allowlist.yml new file mode 100644 index 0000000..ab83a63 --- /dev/null +++ b/.github/deps-allowlist.yml @@ -0,0 +1,6 @@ +# Dependency vulnerability allowlist with expiry model. +# HIGH-severity exceptions require security on-call approval. +# Mirror approved entries into .osv-scanner.toml [[IgnoredVulns]] with +# matching ignoreUntil dates. Expired entries fail CI via +# scripts/check-deps-allowlist.sh. +allowlist: [] diff --git a/.github/workflows/deps-scan.yml b/.github/workflows/deps-scan.yml new file mode 100644 index 0000000..f95d160 --- /dev/null +++ b/.github/workflows/deps-scan.yml @@ -0,0 +1,58 @@ +name: deps-scan + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "0 7 * * 1" + +permissions: + contents: read + +jobs: + allowlist-expiry: + name: "IntentProof Security: Deps Allowlist" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate dependency allowlist expiry dates + run: bash ./scripts/check-deps-allowlist.sh + + pip-audit: + name: "IntentProof Security: pip-audit" + needs: allowlist-expiry + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package and pip-audit + run: pip install -e ".[dev]" pip-audit + + - name: Run pip-audit + run: pip-audit --desc on + + osv-scanner: + name: "IntentProof Security: OSV-Scanner" + needs: allowlist-expiry + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Materialize Python lockfile for OSV + run: | + pip install -e ".[dev]" + pip freeze --exclude-editable > requirements-osv.txt + + - name: Run OSV-Scanner gate + run: bash ./scripts/run-osv-scanner-gate.sh . .osv-scanner.toml requirements-osv.txt diff --git a/.osv-scanner.toml b/.osv-scanner.toml new file mode 100644 index 0000000..393cdb0 --- /dev/null +++ b/.osv-scanner.toml @@ -0,0 +1,4 @@ +# IntentProof OSV-Scanner configuration. +# CRITICAL and HIGH findings fail CI via deps-scan workflow severity filter. +# Time-bounded HIGH allowlist entries live in .github/deps-allowlist.yml and +# must be mirrored here under [[IgnoredVulns]] with matching ignoreUntil dates. diff --git a/scripts/check-allowlist-expiry.sh b/scripts/check-allowlist-expiry.sh new file mode 100755 index 0000000..db93e7c --- /dev/null +++ b/scripts/check-allowlist-expiry.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Validate allowlist YAML expiry dates (minimal parser, no PyYAML). +# Usage: check-allowlist-expiry.sh [id-field...] +set -euo pipefail + +if [[ $# -lt 1 || -z "${1:-}" ]]; then + echo "usage: check-allowlist-expiry.sh [id-field...]" >&2 + exit 1 +fi + +ALLOWLIST_FILE="$1" +shift +ID_FIELDS=("$@") +if ((${#ID_FIELDS[@]} == 0)); then + ID_FIELDS=(rule_id) +fi + +if [[ ! -f "$ALLOWLIST_FILE" ]]; then + echo "No allowlist file at $ALLOWLIST_FILE; skipping expiry check." + exit 0 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required to validate $ALLOWLIST_FILE" >&2 + exit 1 +fi + +python3 - "$ALLOWLIST_FILE" "${ID_FIELDS[*]}" <<'PY' +import datetime +import re +import sys + +path = sys.argv[1] +id_fields = sys.argv[2].split() +patterns = { + "rule_id": re.compile(r"rule_id:\s*(\S+)"), + "cve_id": re.compile(r"cve_id:\s*(\S+)"), + "id": re.compile(r"(?:^|\s)id:\s*(\S+)"), +} + + +def extract_id(text, anchored=False): + for field in id_fields: + pattern = patterns.get(field) + if pattern is None: + continue + match = pattern.match(text) if anchored else pattern.search(text) + if match: + return match.group(1) + return None + + +text = open(path, encoding="utf-8").read() +entries = [] +current = None +for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("- "): + if current is not None: + entries.append(current) + current = {} + item = stripped[2:].strip() + entry_id = extract_id(item, anchored=False) + if entry_id: + current["id"] = entry_id + match = re.search(r"expires:\s*(\S+)", item) + if match: + current["expires"] = match.group(1) + continue + if current is None: + continue + match = re.match(r"expires:\s*(\S+)", stripped) + if match: + current["expires"] = match.group(1) + entry_id = extract_id(stripped, anchored=True) + if entry_id: + current["id"] = entry_id +if current is not None: + entries.append(current) + +today = datetime.date.today() +expired = [] +for idx, entry in enumerate(entries): + expires_raw = entry.get("expires") + if not expires_raw: + print( + f"{path}: allowlist[{idx}] missing expires date " + "(required for security on-call approval model)", + file=sys.stderr, + ) + sys.exit(1) + try: + expires = datetime.date.fromisoformat(str(expires_raw)) + except ValueError: + print( + f"{path}: allowlist[{idx}] has invalid expires date: {expires_raw!r}", + file=sys.stderr, + ) + sys.exit(1) + if expires < today: + entry_id = entry.get("id", "") + expired.append(f"{entry_id} (expired {expires.isoformat()})") + +if expired: + print("Allowlist expired; contact security on-call to extend or remove:", file=sys.stderr) + for item in expired: + print(f" - {item}", file=sys.stderr) + sys.exit(1) + +print(f"PASS: {len(entries)} allowlist entries are current.") +PY diff --git a/scripts/check-codeql-allowlist.sh b/scripts/check-codeql-allowlist.sh index 4c9072a..9e58438 100755 --- a/scripts/check-codeql-allowlist.sh +++ b/scripts/check-codeql-allowlist.sh @@ -1,84 +1,3 @@ #!/usr/bin/env bash # Validate .github/codeql-allowlist.yml expiry dates. -# Expired entries fail with a clear message for security on-call follow-up. -set -euo pipefail - -ALLOWLIST_FILE="${1:-.github/codeql-allowlist.yml}" - -if [[ ! -f "$ALLOWLIST_FILE" ]]; then - echo "No allowlist file at $ALLOWLIST_FILE; skipping expiry check." - exit 0 -fi - -if ! command -v python3 >/dev/null 2>&1; then - echo "python3 is required to validate $ALLOWLIST_FILE" >&2 - exit 1 -fi - -python3 - "$ALLOWLIST_FILE" <<'PY' -import datetime -import re -import sys - -path = sys.argv[1] -text = open(path, encoding="utf-8").read() - -# Minimal parser for our allowlist schema (no PyYAML dependency). -entries = [] -current = None -for line in text.splitlines(): - stripped = line.strip() - if stripped.startswith("- "): - if current: - entries.append(current) - current = {} - item = stripped[2:].strip() - m = re.search(r"rule_id:\s*(\S+)", item) - if m: - current["rule_id"] = m.group(1) - m = re.search(r"expires:\s*(\S+)", item) - if m: - current["expires"] = m.group(1) - continue - if current is None: - continue - m = re.match(r"expires:\s*(\S+)", stripped) - if m: - current["expires"] = m.group(1) - m = re.match(r"rule_id:\s*(\S+)", stripped) - if m: - current["rule_id"] = m.group(1) -if current: - entries.append(current) - -today = datetime.date.today() -expired = [] -for idx, entry in enumerate(entries): - expires_raw = entry.get("expires") - if not expires_raw: - print( - f"{path}: allowlist[{idx}] missing expires date " - "(required for security on-call approval model)", - file=sys.stderr, - ) - sys.exit(1) - try: - expires = datetime.date.fromisoformat(str(expires_raw)) - except ValueError: - print( - f"{path}: allowlist[{idx}] has invalid expires date: {expires_raw!r}", - file=sys.stderr, - ) - sys.exit(1) - if expires < today: - rule_id = entry.get("rule_id", "") - expired.append(f"{rule_id} (expired {expires.isoformat()})") - -if expired: - print("Allowlist expired; contact security on-call to extend or remove:", file=sys.stderr) - for item in expired: - print(f" - {item}", file=sys.stderr) - sys.exit(1) - -print(f"PASS: {len(entries)} allowlist entries are current.") -PY +exec "$(dirname "$0")/check-allowlist-expiry.sh" "${1:-.github/codeql-allowlist.yml}" rule_id diff --git a/scripts/check-deps-allowlist.sh b/scripts/check-deps-allowlist.sh new file mode 100755 index 0000000..698999e --- /dev/null +++ b/scripts/check-deps-allowlist.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Validate .github/deps-allowlist.yml expiry dates. +exec "$(dirname "$0")/check-allowlist-expiry.sh" "${1:-.github/deps-allowlist.yml}" rule_id cve_id id diff --git a/scripts/run-osv-scanner-gate.sh b/scripts/run-osv-scanner-gate.sh new file mode 100755 index 0000000..8d1cece --- /dev/null +++ b/scripts/run-osv-scanner-gate.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Run OSV-Scanner and fail only on CRITICAL or HIGH findings. +# MEDIUM and LOW are reported but non-blocking per SECURITY-PROCESS.md. +set -euo pipefail + +ROOT="${1:-.}" +CONFIG="${2:-.osv-scanner.toml}" +if (( $# > 2 )); then + EXTRA_LOCKFILES=("${@:3}") +else + EXTRA_LOCKFILES=() +fi +OSV_VERSION="${OSV_SCANNER_VERSION:-v2.2.2}" + +if ! command -v osv-scanner >/dev/null 2>&1; then + arch=linux_amd64 + case "$(uname -m)" in + aarch64 | arm64) arch=linux_arm64 ;; + x86_64 | amd64) arch=linux_amd64 ;; + esac + tmp="$(mktemp)" + curl -sSfL \ + "https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_${arch}" \ + -o "$tmp" + chmod +x "$tmp" + sudo install -m 755 "$tmp" /usr/local/bin/osv-scanner + rm -f "$tmp" +fi + +args=(scan source --format=table --no-call-analysis=all --allow-no-lockfiles) +if [[ -f "$CONFIG" ]]; then + args+=(--config="$CONFIG") +fi + +if ((${#EXTRA_LOCKFILES[@]} > 0)); then + for lockfile in "${EXTRA_LOCKFILES[@]}"; do + args+=(--lockfile="$lockfile") + done +else + args+=(--recursive "$ROOT") +fi + +output_file="$(mktemp)" +trap 'rm -f "$output_file"' EXIT + +set +e +osv-scanner "${args[@]}" >"$output_file" 2>&1 +status=$? +set -e + +cat "$output_file" + +if [[ "$status" -eq 128 ]] && grep -q "No package sources found" "$output_file"; then + echo "PASS: no scannable dependency manifests (OSV skipped)" + exit 0 +fi + +if [[ "$status" -gt 1 ]]; then + echo "osv-scanner failed unexpectedly (exit $status)" >&2 + exit "$status" +fi + +python3 - "$output_file" <<'PY' +import re +import sys + +with open(sys.argv[1], encoding="utf-8") as fh: + text = fh.read() + +if "No issues found" in text: + print("PASS: no OSV findings") + raise SystemExit(0) + +match = re.search( + r"\((\d+) Critical, (\d+) High, (\d+) Medium, (\d+) Low", + text, +) +if not match: + if re.search(r"\b(GHSA-[a-z0-9-]+|GO-\d{4}-\d+|CVE-\d{4}-\d+)\b", text): + print( + "FAIL: OSV findings present but severity summary missing", + file=sys.stderr, + ) + raise SystemExit(1) + print("PASS: no parseable HIGH/CRITICAL OSV findings") + raise SystemExit(0) + +critical = int(match.group(1)) +high = int(match.group(2)) +if critical or high: + print( + f"FAIL: OSV found {critical} Critical and {high} High findings", + file=sys.stderr, + ) + raise SystemExit(1) + +print( + "PASS: OSV gate " + f"(Critical={critical}, High={high}; Medium/Low non-blocking)" +) +PY