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
20 changes: 18 additions & 2 deletions .github/workflows/devsecops.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
# 3. SCA — pip-audit
# 4. Container Security — Trivy (skipped if no Dockerfile)
# 5. Code Quality — Ruff
# 6. Security Gate — blocks merge on critical findings
# 6. Template Lint — yamllint on templates/**/*.yml
# 7. Security Gate — blocks merge on critical findings

name: CAST DevSecOps

Expand Down Expand Up @@ -35,7 +36,7 @@
fetch-depth: 0 # full history so Gitleaks can scan all commits
- name: Install Gitleaks
run: |
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.27.2/gitleaks_8.27.2_linux_x64.tar.gz \

Check warning on line 39 in .github/workflows/devsecops.yml

View workflow job for this annotation

GitHub Actions / Template Lint

39:81 [line-length] line too long (118 > 80 characters)
-o /tmp/gitleaks.tar.gz
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
sudo mv /tmp/gitleaks /usr/local/bin/gitleaks
Expand All @@ -59,11 +60,11 @@
- name: Ensure SARIF exists
if: always()
run: |
[ -f semgrep.sarif ] || echo '{"version":"2.1.0","$schema":"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json","runs":[]}' > semgrep.sarif

Check warning on line 63 in .github/workflows/devsecops.yml

View workflow job for this annotation

GitHub Actions / Template Lint

63:81 [line-length] line too long (193 > 80 characters)
- name: Upload to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
continue-on-error: true # requires GitHub Advanced Security; skip gracefully if not enabled

Check warning on line 67 in .github/workflows/devsecops.yml

View workflow job for this annotation

GitHub Actions / Template Lint

67:81 [line-length] line too long (100 > 80 characters)
with:
sarif_file: semgrep.sarif

Expand All @@ -82,7 +83,7 @@
pip install pip-audit
pip install -e ".[dev]"
# CVE-2026-4539: ReDoS in pygments AdlLexer — no fix released yet
# (https://github.com/advisories/GHSA-5239-wwwm-4pmq); re-enable once patched

Check warning on line 86 in .github/workflows/devsecops.yml

View workflow job for this annotation

GitHub Actions / Template Lint

86:81 [line-length] line too long (87 > 80 characters)
pip-audit --ignore-vuln CVE-2026-4539

# ── 4. Container Security ──────────────────────────────────────────────────
Expand Down Expand Up @@ -115,7 +116,7 @@
- name: Upload to GitHub Security tab
if: steps.check_dockerfile.outputs.found == 'true'
uses: github/codeql-action/upload-sarif@v3
continue-on-error: true # requires GitHub Advanced Security; skip gracefully if not enabled

Check warning on line 119 in .github/workflows/devsecops.yml

View workflow job for this annotation

GitHub Actions / Template Lint

119:81 [line-length] line too long (100 > 80 characters)
with:
sarif_file: trivy.sarif

Expand All @@ -127,7 +128,22 @@
- uses: actions/checkout@v4
- uses: astral-sh/ruff-action@v1

# ── 6. Security Gate ───────────────────────────────────────────────────────
# ── 6. Template Lint ───────────────────────────────────────────────────────
template-lint:
name: Template Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install yamllint
run: pip install yamllint
- name: Lint workflow files
# Templates embed shell heredocs whose content is intentionally at column 1

Check warning on line 140 in .github/workflows/devsecops.yml

View workflow job for this annotation

GitHub Actions / Template Lint

140:81 [line-length] line too long (83 > 80 characters)
# (required by bash heredoc syntax). GitHub Actions handles this correctly,

Check warning on line 141 in .github/workflows/devsecops.yml

View workflow job for this annotation

GitHub Actions / Template Lint

141:81 [line-length] line too long (83 > 80 characters)
# but yamllint's YAML parser reports false-positive syntax errors for such files.

Check warning on line 142 in .github/workflows/devsecops.yml

View workflow job for this annotation

GitHub Actions / Template Lint

142:81 [line-length] line too long (89 > 80 characters)
# Only lint pure workflow YAML files in .github/workflows/.
run: yamllint -d relaxed .github/workflows/*.yml

# ── 7. Security Gate ───────────────────────────────────────────────────────
gate:
name: Security Gate
runs-on: ubuntu-latest
Expand Down
54 changes: 54 additions & 0 deletions scripts/check-template-sync.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# check-template-sync.sh — Verify that templates/ and src/cast_cli/templates/
# stay in sync. Exits 1 if any devsecops.yml template has drifted.
#
# Usage: bash scripts/check-template-sync.sh
#
# The templates/ directory is the canonical "curl-download" copy.
# The src/cast_cli/templates/ directory is the embedded CLI copy.
# They must remain identical for every devsecops.yml file.

set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CANONICAL="$REPO_ROOT/templates"
EMBEDDED="$REPO_ROOT/src/cast_cli/templates"

drift=0

# Platforms and stacks to check
declare -a STACKS=("python" "nodejs" "go")
declare -a PLATFORMS=("" "gitlab/") # "" = github (no subdirectory prefix in templates/)

for stack in "${STACKS[@]}"; do
# GitHub templates: templates/<stack>/devsecops.yml vs src/cast_cli/templates/<stack>/devsecops.yml
src="$CANONICAL/$stack/devsecops.yml"
dst="$EMBEDDED/$stack/devsecops.yml"
if ! diff -q "$src" "$dst" > /dev/null 2>&1; then
echo "DRIFT: $stack/devsecops.yml (github)"
echo " canonical : $src"
echo " embedded : $dst"
drift=1
fi

# GitLab templates: templates/gitlab/<stack>/devsecops.yml vs src/cast_cli/templates/gitlab/<stack>/devsecops.yml
src="$CANONICAL/gitlab/$stack/devsecops.yml"
dst="$EMBEDDED/gitlab/$stack/devsecops.yml"
if ! diff -q "$src" "$dst" > /dev/null 2>&1; then
echo "DRIFT: gitlab/$stack/devsecops.yml"
echo " canonical : $src"
echo " embedded : $dst"
drift=1
fi
done

if [ "$drift" -eq 0 ]; then
echo "✓ All templates in sync (templates/ == src/cast_cli/templates/)"
exit 0
else
echo ""
echo "✗ Template drift detected."
echo " Update the embedded copies to match the canonical templates/ directory."
echo " Run: diff -r templates/ src/cast_cli/templates/ --exclude='*.py' --exclude='__pycache__'"
exit 1
fi
145 changes: 144 additions & 1 deletion src/cast_cli/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""CAST CLI — entry point."""

import json
import os
import sys
from pathlib import Path
from typing import Optional
Expand Down Expand Up @@ -86,7 +88,7 @@ def init(
detected = project_type or detect_project(Path("."))

if detected is None:
if sys.stdout.isatty():
if sys.stdin.isatty():
detected = _prompt_type_selection(console)
else:
console.print("[yellow]Could not detect project type.[/yellow]")
Expand Down Expand Up @@ -162,3 +164,144 @@ def init(
" [bold]git commit -m 'ci: add CAST DevSecOps pipeline'[/bold]\n"
" [bold]git push[/bold]"
)


# ── Gate policy logic (mirrors policy/*.rego, no OPA dependency) ──────────────

_VALID_POLICIES = ("default", "strict", "permissive")

# Truncate long finding messages in validate output to keep lines readable.
_MAX_MESSAGE_LENGTH = 120


def _apply_gate(runs: list, policy: str) -> tuple[list[str], int]:
"""Return (blocked_messages, blocked_count) for the given policy."""
blocked: list[str] = []
for run in runs:
tool = run.get("tool", {}).get("driver", {}).get("name", "unknown")
for result in run.get("results", []):
level = result.get("level", "note")
rule_id = result.get("ruleId", "")
msg = result.get("message", {}).get("text", "")[:_MAX_MESSAGE_LENGTH]
if policy == "default" and level == "error":
blocked.append(f"[CRITICAL] {tool} — {msg} (rule: {rule_id})")
elif policy == "strict" and level in ("error", "warning"):
label = "CRITICAL" if level == "error" else "HIGH"
blocked.append(f"[{label}] {tool} — {msg} (rule: {rule_id})")
# permissive: never blocked
return blocked, len(blocked)


@app.command()
def validate(
sarif_file: Path = typer.Argument(..., help="Path to a SARIF file to validate."),
policy: Optional[str] = typer.Option(
None,
"--policy",
help="Gate policy: default / strict / permissive. "
"Falls back to CAST_POLICY env var, then 'default'.",
),
) -> None:
"""Validate a SARIF file and preview cast-gate blocking behavior.

Exit codes:
0 — SARIF valid and gate would allow
1 — SARIF format error (invalid JSON or missing required fields)
2 — SARIF valid but gate would block
"""
effective_policy = policy or os.environ.get("CAST_POLICY", "default")

if effective_policy not in _VALID_POLICIES:
console.print(
f"[red]Unknown policy:[/red] {effective_policy!r} "
f"(valid: {', '.join(_VALID_POLICIES)})"
)
raise typer.Exit(1)

# ── load file ─────────────────────────────────────────────────────────────
try:
text = sarif_file.read_text(encoding="utf-8")
except OSError as e:
console.print(f"[red]Cannot read file:[/red] {e}")
raise typer.Exit(1)

# ── parse JSON ────────────────────────────────────────────────────────────
try:
data = json.loads(text)
except json.JSONDecodeError as e:
console.print(f"[red]✗ Invalid JSON:[/red] {e}")
raise typer.Exit(1)

# ── structural validation ─────────────────────────────────────────────────
format_errors: list[str] = []
if not isinstance(data, dict):
format_errors.append("Top-level value must be a JSON object")
else:
version = data.get("version")
if version != "2.1.0":
format_errors.append(f'version must be "2.1.0", got: {version!r}')
runs = data.get("runs")
if runs is None:
format_errors.append('Missing required field: "runs"')
elif not isinstance(runs, list):
format_errors.append('"runs" must be an array')
else:
for i, run in enumerate(runs):
if not isinstance(run, dict):
format_errors.append(f"runs[{i}] must be an object")
continue
driver = run.get("tool", {}).get("driver", {})
if not driver.get("name"):
format_errors.append(f"runs[{i}].tool.driver.name is missing or empty")

if format_errors:
console.print("[red]✗ SARIF format errors:[/red]")
for err in format_errors:
console.print(f" • {err}")
raise typer.Exit(1)

# ── count findings ────────────────────────────────────────────────────────
runs = data.get("runs", [])
tools: set[str] = set()
error_count = warning_count = note_count = 0

for run in runs:
tools.add(run.get("tool", {}).get("driver", {}).get("name", "unknown"))
for result in run.get("results", []):
level = result.get("level", "note")
if level == "error":
error_count += 1
elif level == "warning":
warning_count += 1
else:
note_count += 1

total = error_count + warning_count + note_count
tools_str = ", ".join(sorted(tools)) or "none"

# ── gate evaluation ───────────────────────────────────────────────────────
blocked_msgs, blocked_count = _apply_gate(runs, effective_policy)
gate_blocked = blocked_count > 0

# ── output ────────────────────────────────────────────────────────────────
console.print("[bold green]✓[/bold green] SARIF valid")
console.print(f" Tool(s): {tools_str}")
console.print(
f" Findings: {total} "
f"({error_count} error, {warning_count} warning, {note_count} note)"
)
console.print(f" Policy: {effective_policy}")

if gate_blocked:
console.print(
f" Gate: [red]❌ {blocked_count} finding(s) would be blocked[/red]"
)
console.print()
for bm in blocked_msgs[:10]:
console.print(f" [red]•[/red] {bm}")
if len(blocked_msgs) > 10:
console.print(f" ... and {len(blocked_msgs) - 10} more")
raise typer.Exit(2)

console.print(f" Gate: [green]✓ would allow (policy: {effective_policy})[/green]")

29 changes: 14 additions & 15 deletions templates/gitlab/go/devsecops.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,20 +87,6 @@ cast-quality:
cast-gate:
stage: cast-gate
image: alpine:latest
variables:
DEFAULT_REGO: |
package main

import future.keywords.if
import future.keywords.in

deny[msg] if {
run := input.runs[_]
result := run.results[_]
result.level == "error"
tool := run.tool.driver.name
msg := sprintf("[CRITICAL] %s — %s (rule: %s)", [tool, result.message.text, result.ruleId])
}
needs:
- job: cast-secrets
artifacts: false
Expand All @@ -126,7 +112,20 @@ cast-gate:
# https://github.com/castops/cast/tree/main/policy
if [ ! -d policy ] || [ -z "$(ls -A policy/*.rego 2>/dev/null)" ]; then
mkdir -p policy
printf '%s' "$DEFAULT_REGO" > policy/active.rego
cat > policy/active.rego << 'REGO'
package main

import future.keywords.if
import future.keywords.in

deny[msg] if {
run := input.runs[_]
result := run.results[_]
result.level == "error"
tool := run.tool.driver.name
msg := sprintf("[CRITICAL] %s — %s (rule: %s)", [tool, result.message.text, result.ruleId])
}
REGO
fi
- |
SARIF_FILES=""
Expand Down
29 changes: 14 additions & 15 deletions templates/gitlab/nodejs/devsecops.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,20 +87,6 @@ cast-quality:
cast-gate:
stage: cast-gate
image: alpine:latest
variables:
DEFAULT_REGO: |
package main

import future.keywords.if
import future.keywords.in

deny[msg] if {
run := input.runs[_]
result := run.results[_]
result.level == "error"
tool := run.tool.driver.name
msg := sprintf("[CRITICAL] %s — %s (rule: %s)", [tool, result.message.text, result.ruleId])
}
needs:
- job: cast-secrets
artifacts: false
Expand All @@ -126,7 +112,20 @@ cast-gate:
# https://github.com/castops/cast/tree/main/policy
if [ ! -d policy ] || [ -z "$(ls -A policy/*.rego 2>/dev/null)" ]; then
mkdir -p policy
printf '%s' "$DEFAULT_REGO" > policy/active.rego
cat > policy/active.rego << 'REGO'
package main

import future.keywords.if
import future.keywords.in

deny[msg] if {
run := input.runs[_]
result := run.results[_]
result.level == "error"
tool := run.tool.driver.name
msg := sprintf("[CRITICAL] %s — %s (rule: %s)", [tool, result.message.text, result.ruleId])
}
REGO
fi
- |
SARIF_FILES=""
Expand Down
29 changes: 14 additions & 15 deletions templates/gitlab/python/devsecops.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,20 +87,6 @@ cast-quality:
cast-gate:
stage: cast-gate
image: alpine:latest
variables:
DEFAULT_REGO: |
package main

import future.keywords.if
import future.keywords.in

deny[msg] if {
run := input.runs[_]
result := run.results[_]
result.level == "error"
tool := run.tool.driver.name
msg := sprintf("[CRITICAL] %s — %s (rule: %s)", [tool, result.message.text, result.ruleId])
}
needs:
- job: cast-secrets
artifacts: false
Expand All @@ -126,7 +112,20 @@ cast-gate:
# https://github.com/castops/cast/tree/main/policy
if [ ! -d policy ] || [ -z "$(ls -A policy/*.rego 2>/dev/null)" ]; then
mkdir -p policy
printf '%s' "$DEFAULT_REGO" > policy/active.rego
cat > policy/active.rego << 'REGO'
package main

import future.keywords.if
import future.keywords.in

deny[msg] if {
run := input.runs[_]
result := run.results[_]
result.level == "error"
tool := run.tool.driver.name
msg := sprintf("[CRITICAL] %s — %s (rule: %s)", [tool, result.message.text, result.ruleId])
}
REGO
fi
- |
SARIF_FILES=""
Expand Down
Loading
Loading