Skip to content

Commit 965255f

Browse files
authored
Merge pull request #57 from IntentProof/add-deps-scan-gates
Add dependency vulnerability scan CI gates
2 parents c4df3ff + d47b605 commit 965255f

8 files changed

Lines changed: 307 additions & 82 deletions

.github/dependabot.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
version: 2
2+
updates:
3+
- package-ecosystem: pip
4+
directory: /
5+
schedule:
6+
interval: weekly
7+
groups:
8+
patch-updates:
9+
update-types:
10+
- patch
11+
minor-major-updates:
12+
update-types:
13+
- minor
14+
- major
15+
16+
- package-ecosystem: github-actions
17+
directory: /
18+
schedule:
19+
interval: weekly
20+
groups:
21+
github-actions:
22+
patterns:
23+
- "*"

.github/deps-allowlist.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Dependency vulnerability allowlist with expiry model.
2+
# HIGH-severity exceptions require security on-call approval.
3+
# Mirror approved entries into .osv-scanner.toml [[IgnoredVulns]] with
4+
# matching ignoreUntil dates. Expired entries fail CI via
5+
# scripts/check-deps-allowlist.sh.
6+
allowlist: []

.github/workflows/deps-scan.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: deps-scan
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
schedule:
9+
- cron: "0 7 * * 1"
10+
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
allowlist-expiry:
16+
name: "IntentProof Security: Deps Allowlist"
17+
runs-on: ubuntu-latest
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- name: Validate dependency allowlist expiry dates
22+
run: bash ./scripts/check-deps-allowlist.sh
23+
24+
pip-audit:
25+
name: "IntentProof Security: pip-audit"
26+
needs: allowlist-expiry
27+
runs-on: ubuntu-latest
28+
steps:
29+
- uses: actions/checkout@v4
30+
31+
- uses: actions/setup-python@v5
32+
with:
33+
python-version: "3.11"
34+
35+
- name: Install package and pip-audit
36+
run: pip install -e ".[dev]" pip-audit
37+
38+
- name: Run pip-audit
39+
run: pip-audit --desc on
40+
41+
osv-scanner:
42+
name: "IntentProof Security: OSV-Scanner"
43+
needs: allowlist-expiry
44+
runs-on: ubuntu-latest
45+
steps:
46+
- uses: actions/checkout@v4
47+
48+
- uses: actions/setup-python@v5
49+
with:
50+
python-version: "3.11"
51+
52+
- name: Materialize Python lockfile for OSV
53+
run: |
54+
pip install -e ".[dev]"
55+
pip freeze --exclude-editable > requirements-osv.txt
56+
57+
- name: Run OSV-Scanner gate
58+
run: bash ./scripts/run-osv-scanner-gate.sh . .osv-scanner.toml requirements-osv.txt

.osv-scanner.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# IntentProof OSV-Scanner configuration.
2+
# CRITICAL and HIGH findings fail CI via deps-scan workflow severity filter.
3+
# Time-bounded HIGH allowlist entries live in .github/deps-allowlist.yml and
4+
# must be mirrored here under [[IgnoredVulns]] with matching ignoreUntil dates.

scripts/check-allowlist-expiry.sh

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/usr/bin/env bash
2+
# Validate allowlist YAML expiry dates (minimal parser, no PyYAML).
3+
# Usage: check-allowlist-expiry.sh <allowlist-file> [id-field...]
4+
set -euo pipefail
5+
6+
if [[ $# -lt 1 || -z "${1:-}" ]]; then
7+
echo "usage: check-allowlist-expiry.sh <allowlist-file> [id-field...]" >&2
8+
exit 1
9+
fi
10+
11+
ALLOWLIST_FILE="$1"
12+
shift
13+
ID_FIELDS=("$@")
14+
if ((${#ID_FIELDS[@]} == 0)); then
15+
ID_FIELDS=(rule_id)
16+
fi
17+
18+
if [[ ! -f "$ALLOWLIST_FILE" ]]; then
19+
echo "No allowlist file at $ALLOWLIST_FILE; skipping expiry check."
20+
exit 0
21+
fi
22+
23+
if ! command -v python3 >/dev/null 2>&1; then
24+
echo "python3 is required to validate $ALLOWLIST_FILE" >&2
25+
exit 1
26+
fi
27+
28+
python3 - "$ALLOWLIST_FILE" "${ID_FIELDS[*]}" <<'PY'
29+
import datetime
30+
import re
31+
import sys
32+
33+
path = sys.argv[1]
34+
id_fields = sys.argv[2].split()
35+
patterns = {
36+
"rule_id": re.compile(r"rule_id:\s*(\S+)"),
37+
"cve_id": re.compile(r"cve_id:\s*(\S+)"),
38+
"id": re.compile(r"(?:^|\s)id:\s*(\S+)"),
39+
}
40+
41+
42+
def extract_id(text, anchored=False):
43+
for field in id_fields:
44+
pattern = patterns.get(field)
45+
if pattern is None:
46+
continue
47+
match = pattern.match(text) if anchored else pattern.search(text)
48+
if match:
49+
return match.group(1)
50+
return None
51+
52+
53+
text = open(path, encoding="utf-8").read()
54+
entries = []
55+
current = None
56+
for line in text.splitlines():
57+
stripped = line.strip()
58+
if stripped.startswith("- "):
59+
if current is not None:
60+
entries.append(current)
61+
current = {}
62+
item = stripped[2:].strip()
63+
entry_id = extract_id(item, anchored=False)
64+
if entry_id:
65+
current["id"] = entry_id
66+
match = re.search(r"expires:\s*(\S+)", item)
67+
if match:
68+
current["expires"] = match.group(1)
69+
continue
70+
if current is None:
71+
continue
72+
match = re.match(r"expires:\s*(\S+)", stripped)
73+
if match:
74+
current["expires"] = match.group(1)
75+
entry_id = extract_id(stripped, anchored=True)
76+
if entry_id:
77+
current["id"] = entry_id
78+
if current is not None:
79+
entries.append(current)
80+
81+
today = datetime.date.today()
82+
expired = []
83+
for idx, entry in enumerate(entries):
84+
expires_raw = entry.get("expires")
85+
if not expires_raw:
86+
print(
87+
f"{path}: allowlist[{idx}] missing expires date "
88+
"(required for security on-call approval model)",
89+
file=sys.stderr,
90+
)
91+
sys.exit(1)
92+
try:
93+
expires = datetime.date.fromisoformat(str(expires_raw))
94+
except ValueError:
95+
print(
96+
f"{path}: allowlist[{idx}] has invalid expires date: {expires_raw!r}",
97+
file=sys.stderr,
98+
)
99+
sys.exit(1)
100+
if expires < today:
101+
entry_id = entry.get("id", "<unknown>")
102+
expired.append(f"{entry_id} (expired {expires.isoformat()})")
103+
104+
if expired:
105+
print("Allowlist expired; contact security on-call to extend or remove:", file=sys.stderr)
106+
for item in expired:
107+
print(f" - {item}", file=sys.stderr)
108+
sys.exit(1)
109+
110+
print(f"PASS: {len(entries)} allowlist entries are current.")
111+
PY

scripts/check-codeql-allowlist.sh

Lines changed: 1 addition & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,3 @@
11
#!/usr/bin/env bash
22
# Validate .github/codeql-allowlist.yml expiry dates.
3-
# Expired entries fail with a clear message for security on-call follow-up.
4-
set -euo pipefail
5-
6-
ALLOWLIST_FILE="${1:-.github/codeql-allowlist.yml}"
7-
8-
if [[ ! -f "$ALLOWLIST_FILE" ]]; then
9-
echo "No allowlist file at $ALLOWLIST_FILE; skipping expiry check."
10-
exit 0
11-
fi
12-
13-
if ! command -v python3 >/dev/null 2>&1; then
14-
echo "python3 is required to validate $ALLOWLIST_FILE" >&2
15-
exit 1
16-
fi
17-
18-
python3 - "$ALLOWLIST_FILE" <<'PY'
19-
import datetime
20-
import re
21-
import sys
22-
23-
path = sys.argv[1]
24-
text = open(path, encoding="utf-8").read()
25-
26-
# Minimal parser for our allowlist schema (no PyYAML dependency).
27-
entries = []
28-
current = None
29-
for line in text.splitlines():
30-
stripped = line.strip()
31-
if stripped.startswith("- "):
32-
if current:
33-
entries.append(current)
34-
current = {}
35-
item = stripped[2:].strip()
36-
m = re.search(r"rule_id:\s*(\S+)", item)
37-
if m:
38-
current["rule_id"] = m.group(1)
39-
m = re.search(r"expires:\s*(\S+)", item)
40-
if m:
41-
current["expires"] = m.group(1)
42-
continue
43-
if current is None:
44-
continue
45-
m = re.match(r"expires:\s*(\S+)", stripped)
46-
if m:
47-
current["expires"] = m.group(1)
48-
m = re.match(r"rule_id:\s*(\S+)", stripped)
49-
if m:
50-
current["rule_id"] = m.group(1)
51-
if current:
52-
entries.append(current)
53-
54-
today = datetime.date.today()
55-
expired = []
56-
for idx, entry in enumerate(entries):
57-
expires_raw = entry.get("expires")
58-
if not expires_raw:
59-
print(
60-
f"{path}: allowlist[{idx}] missing expires date "
61-
"(required for security on-call approval model)",
62-
file=sys.stderr,
63-
)
64-
sys.exit(1)
65-
try:
66-
expires = datetime.date.fromisoformat(str(expires_raw))
67-
except ValueError:
68-
print(
69-
f"{path}: allowlist[{idx}] has invalid expires date: {expires_raw!r}",
70-
file=sys.stderr,
71-
)
72-
sys.exit(1)
73-
if expires < today:
74-
rule_id = entry.get("rule_id", "<unknown>")
75-
expired.append(f"{rule_id} (expired {expires.isoformat()})")
76-
77-
if expired:
78-
print("Allowlist expired; contact security on-call to extend or remove:", file=sys.stderr)
79-
for item in expired:
80-
print(f" - {item}", file=sys.stderr)
81-
sys.exit(1)
82-
83-
print(f"PASS: {len(entries)} allowlist entries are current.")
84-
PY
3+
exec "$(dirname "$0")/check-allowlist-expiry.sh" "${1:-.github/codeql-allowlist.yml}" rule_id

scripts/check-deps-allowlist.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/usr/bin/env bash
2+
# Validate .github/deps-allowlist.yml expiry dates.
3+
exec "$(dirname "$0")/check-allowlist-expiry.sh" "${1:-.github/deps-allowlist.yml}" rule_id cve_id id

scripts/run-osv-scanner-gate.sh

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
#!/usr/bin/env bash
2+
# Run OSV-Scanner and fail only on CRITICAL or HIGH findings.
3+
# MEDIUM and LOW are reported but non-blocking per SECURITY-PROCESS.md.
4+
set -euo pipefail
5+
6+
ROOT="${1:-.}"
7+
CONFIG="${2:-.osv-scanner.toml}"
8+
if (( $# > 2 )); then
9+
EXTRA_LOCKFILES=("${@:3}")
10+
else
11+
EXTRA_LOCKFILES=()
12+
fi
13+
OSV_VERSION="${OSV_SCANNER_VERSION:-v2.2.2}"
14+
15+
if ! command -v osv-scanner >/dev/null 2>&1; then
16+
arch=linux_amd64
17+
case "$(uname -m)" in
18+
aarch64 | arm64) arch=linux_arm64 ;;
19+
x86_64 | amd64) arch=linux_amd64 ;;
20+
esac
21+
tmp="$(mktemp)"
22+
curl -sSfL \
23+
"https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_${arch}" \
24+
-o "$tmp"
25+
chmod +x "$tmp"
26+
sudo install -m 755 "$tmp" /usr/local/bin/osv-scanner
27+
rm -f "$tmp"
28+
fi
29+
30+
args=(scan source --format=table --no-call-analysis=all --allow-no-lockfiles)
31+
if [[ -f "$CONFIG" ]]; then
32+
args+=(--config="$CONFIG")
33+
fi
34+
35+
if ((${#EXTRA_LOCKFILES[@]} > 0)); then
36+
for lockfile in "${EXTRA_LOCKFILES[@]}"; do
37+
args+=(--lockfile="$lockfile")
38+
done
39+
else
40+
args+=(--recursive "$ROOT")
41+
fi
42+
43+
output_file="$(mktemp)"
44+
trap 'rm -f "$output_file"' EXIT
45+
46+
set +e
47+
osv-scanner "${args[@]}" >"$output_file" 2>&1
48+
status=$?
49+
set -e
50+
51+
cat "$output_file"
52+
53+
if [[ "$status" -eq 128 ]] && grep -q "No package sources found" "$output_file"; then
54+
echo "PASS: no scannable dependency manifests (OSV skipped)"
55+
exit 0
56+
fi
57+
58+
if [[ "$status" -gt 1 ]]; then
59+
echo "osv-scanner failed unexpectedly (exit $status)" >&2
60+
exit "$status"
61+
fi
62+
63+
python3 - "$output_file" <<'PY'
64+
import re
65+
import sys
66+
67+
with open(sys.argv[1], encoding="utf-8") as fh:
68+
text = fh.read()
69+
70+
if "No issues found" in text:
71+
print("PASS: no OSV findings")
72+
raise SystemExit(0)
73+
74+
match = re.search(
75+
r"\((\d+) Critical, (\d+) High, (\d+) Medium, (\d+) Low",
76+
text,
77+
)
78+
if not match:
79+
if re.search(r"\b(GHSA-[a-z0-9-]+|GO-\d{4}-\d+|CVE-\d{4}-\d+)\b", text):
80+
print(
81+
"FAIL: OSV findings present but severity summary missing",
82+
file=sys.stderr,
83+
)
84+
raise SystemExit(1)
85+
print("PASS: no parseable HIGH/CRITICAL OSV findings")
86+
raise SystemExit(0)
87+
88+
critical = int(match.group(1))
89+
high = int(match.group(2))
90+
if critical or high:
91+
print(
92+
f"FAIL: OSV found {critical} Critical and {high} High findings",
93+
file=sys.stderr,
94+
)
95+
raise SystemExit(1)
96+
97+
print(
98+
"PASS: OSV gate "
99+
f"(Critical={critical}, High={high}; Medium/Low non-blocking)"
100+
)
101+
PY

0 commit comments

Comments
 (0)