Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
732162c
πŸ›‘οΈ Sentinel: [security improvement]
seonghobae Jul 8, 2026
973ab39
πŸ›‘οΈ Sentinel: [security improvement]
seonghobae Jul 9, 2026
45dabe2
chore(security): harden workflows and verification
seonghobae Jul 9, 2026
be784cc
Merge remote-tracking branch 'origin/develop' into pr-256
seonghobae Jul 9, 2026
ee55eb4
ci: add repo-local codeql analysis
seonghobae Jul 9, 2026
4b0b684
ci: avoid CodeQL default setup collision
seonghobae Jul 9, 2026
45ed3e8
πŸ›‘οΈ Sentinel: Fix CodeQL CI and SSRF security improvement
seonghobae Jul 9, 2026
50b66f6
test(ci): restore docstring and path checks
seonghobae Jul 9, 2026
908d3e0
ci: address scorecard medium alerts
seonghobae Jul 9, 2026
2d4aa09
ci: expose codeql default setup to scorecard
seonghobae Jul 9, 2026
c8aac26
ci: mark codeql analyze for scorecard
seonghobae Jul 9, 2026
9b64c23
πŸ›‘οΈ Sentinel: Fix CodeQL CI and SSRF security improvement
seonghobae Jul 9, 2026
ca41f6b
ci: restore scorecard and review evidence
seonghobae Jul 9, 2026
71fb82c
Merge develop into PR 256
seonghobae Jul 9, 2026
58461f8
πŸ›‘οΈ Sentinel: Fix CodeQL CI and SSRF security improvement
seonghobae Jul 9, 2026
b015cf7
ci: reapply scorecard evidence after sentinel
seonghobae Jul 9, 2026
86d5d1b
πŸ›‘οΈ Sentinel: Fix CodeQL CI and SSRF security improvement
seonghobae Jul 9, 2026
f91e1a8
ci: restore scorecard evidence after sentinel
seonghobae Jul 9, 2026
27ffc95
πŸ›‘οΈ Sentinel: Fix CodeQL CI and SSRF security improvement
seonghobae Jul 10, 2026
19f196c
fix(ci): restore cross-platform evidence checks
seonghobae Jul 10, 2026
28a8760
ci: harden workflow permissions and pins
seonghobae Jul 10, 2026
30d1d99
fix(ci): document hostname validation fallback
seonghobae Jul 10, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/org-security-failure-collector.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
- name: Create org installation token
if: steps.app-config.outputs.configured == 'true'
id: app-token
uses: actions/create-github-app-token@v3
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
app-id: ${{ env.ORG_SECURITY_FAILURE_APP_ID }}
private-key: ${{ env.ORG_SECURITY_FAILURE_APP_PRIVATE_KEY }}
Expand Down
8 changes: 5 additions & 3 deletions .github/workflows/prepare-pypi-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ on:
type: string

permissions:
actions: write
contents: write
pull-requests: write
contents: read

jobs:
prepare-release:
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
RELEASE_VERSION: ${{ inputs.version }}
Expand Down
4 changes: 0 additions & 4 deletions .github/workflows/scorecard-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,3 @@ jobs:
results_format: sarif
publish_results: true

- name: Upload to code scanning
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
sarif_file: results.sarif
12 changes: 1 addition & 11 deletions .github/workflows/security-process.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ on:

permissions:
contents: read
security-events: write
actions: read

jobs:
Expand Down Expand Up @@ -60,7 +59,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Run Trivy FS scan
uses: aquasecurity/trivy-action@v0.36.0
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
scan-type: fs
scan-ref: .
Expand All @@ -71,15 +70,6 @@ jobs:
output: trivy-results.sarif
exit-code: "1"

- name: Upload Trivy SARIF to code scanning
if: >-
always()
&& hashFiles('trivy-results.sarif') != ''
&& (github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name == github.repository)
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: trivy-results.sarif

- name: Upload Trivy SARIF artifact
if: always() && hashFiles('trivy-results.sarif') != ''
Expand Down
14 changes: 14 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,17 @@
**Vulnerability:** The CLI file scanner `vibesec scan` lacked detection rules for insecure deserialization, such as `pickle.load` or `yaml.load` in Python, which could lead to arbitrary code execution.
**Learning:** Adding regular expressions that detect known dangerous serialization libraries prevents severe security vulnerabilities when parsing untrusted data.
**Prevention:** A new scanner rule `python-insecure-deserialization` was added to `SCAN_RULES` to flag `pickle.load(s)`, `yaml.load`, and `marshal.load(s)`.
## 2025-02-28 - Prevent Security Theater in Validation Logic
**Vulnerability:** Redundant, useless security checks ("security theater") were previously added to statically hardcoded URL strings in GitHub actions and test files, under the guise of SSRF prevention.
**Learning:** Checking a hardcoded string like `"https://pypi.org/..."` to see if it starts with `https://` provides zero real security value while polluting the codebase and making PR reviews noisy. Security validation logic must be strictly scoped to where *untrusted, dynamic input* is introduced (like dynamically constructed API URLs in network wrappers).
**Prevention:** Avoid blanket application of security validation rules across an entire repository regardless of context. Always trace the source of the variable being validated to ensure it actually incorporates dynamic or external data before adding runtime verification logic.
## 2025-02-28 - Fix CodeQL Code Scanning Upload Error
**Vulnerability:** CI fails with error "CodeQL analyses from advanced configurations cannot be processed when the default setup is enabled" when uploading SARIF results.
**Learning:** If a repository has default Code Scanning setup enabled in GitHub settings, pushing SARIF using the `github/codeql-action/upload-sarif` action from advanced workflows fails. This conflict happens when security tooling pushes SARIF files to the GitHub Advanced Security Code Scanning endpoint.
**Prevention:** Remove `github/codeql-action/upload-sarif` steps from advanced configuration workflows (like `scorecard-analysis.yml` and `security-process.yml`) if default CodeQL setup is enabled for the repository, to avoid pipeline failures and configuration conflicts.
## 2025-02-28 - Enhance SSRF Protection with IP Validation
**Vulnerability:** Weak redirect target validation for log collection flows in CI scripts. The `_validate_log_download_url` function blocked a static list of hosts (like `localhost` and `127.0.0.1`), but failed to block private/internal IP ranges (10.0.0.0/8, 192.168.0.0/16, etc), numeric encodings (like `2130706433`), or link-local IPv6 addresses.
**Learning:** Checking hostnames against a hardcoded blocklist is insufficient for SSRF protection because attackers can bypass the list using different IP representations or pointing to other internal IP ranges. Robust SSRF validation requires parsing the IP address using the `ipaddress` module and checking properties like `is_private`, `is_loopback`, `is_link_local`, etc.
**Prevention:** Use `ipaddress.ip_address` to parse and validate IP literals, explicitly rejecting private and reserved addresses before making network requests. Ensure edge cases like dotless decimal encoding and credentials in URLs are also rejected.
## 2025-02-28 - Note on IP Validation and Valid Hostnames
**Learning:** Checking hostnames against patterns like `p.startswith("0")` or `p.startswith("0x")` to prevent octal/hex SSRF bypasses may inadvertently block valid domain names like `0x.org` or `0123.com`. While acceptable for internal tools restricted to known endpoints, it shouldn't be blindly applied to all web applications.
15 changes: 15 additions & 0 deletions appguardrail_core/issueops.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,27 +38,32 @@


def is_failure(conclusion: str | None) -> bool:
"""Return whether a GitHub conclusion represents a failed security run."""
return (conclusion or "").lower() in FAILURES


def is_security_name(*names: str | None) -> bool:
"""Return whether workflow or job names look security-relevant."""
text = " ".join(name or "" for name in names).lower()
return any(term in text for term in SECURITY_TERMS)


def parse_run_url(url: str) -> tuple[str, int]:
"""Extract the repository slug and run id from a GitHub Actions run URL."""
match = re.search(r"github\.com/([^/]+/[^/]+)/actions/runs/(\d+)", url)
if not match:
raise ValueError(f"Unsupported GitHub Actions run URL: {url}")
return match.group(1), int(match.group(2))


def sanitize_label_value(value: str) -> str:
"""Convert arbitrary repository text into a compact GitHub label suffix."""
value = re.sub(r"[^A-Za-z0-9._:-]+", "-", value.strip()).strip("-")
return value[:45] or "unknown"


def redact(log: str) -> str:
"""Remove ANSI noise, timestamps, and obvious secrets from a job log."""
text = ANSI_RE.sub("", log.replace("\r\n", "\n").replace("\r", "\n"))
text = "\n".join(TS_RE.sub("", line) for line in text.splitlines())
for regex in SECRET_RE:
Expand All @@ -67,6 +72,7 @@ def redact(log: str) -> str:


def log_ranges(lines: list[str], patterns: list[re.Pattern[str]]) -> list[tuple[int, int]]:
"""Return compact context windows around lines matching failure patterns."""
return [
(max(0, index - 2), min(len(lines), index + 9))
for index, line in enumerate(lines)
Expand All @@ -75,6 +81,7 @@ def log_ranges(lines: list[str], patterns: list[re.Pattern[str]]) -> list[tuple[


def compress_log(log: str, max_lines: int = DEFAULT_MAX_LOG_LINES, max_chars: int = DEFAULT_MAX_LOG_CHARS) -> str:
"""Compress a full job log to the most useful failure evidence."""
lines = redact(log).splitlines()
if not lines:
return "(no job log returned)"
Expand Down Expand Up @@ -112,15 +119,18 @@ def compress_log(log: str, max_lines: int = DEFAULT_MAX_LOG_LINES, max_chars: in


def seen_key(finding: dict[str, Any]) -> str:
"""Return a stable run/job key used to deduplicate IssueOps updates."""
return f"{finding['run_id']}:{finding['job_id']}"


def marker(repo: str, workflow: str, seen: set[str]) -> str:
"""Build the hidden issue marker that stores repository and seen-job state."""
payload = {"repo": repo, "workflow": workflow, "seen": sorted(seen)}
return f"{MARKER_PREFIX} {json.dumps(payload, sort_keys=True)} {MARKER_SUFFIX}"


def parse_marker(body: str | None) -> dict[str, Any]:
"""Parse a hidden issue marker, returning an empty seen list when absent."""
body = body or ""
start = body.find(MARKER_PREFIX)
end = body.find(MARKER_SUFFIX, start + len(MARKER_PREFIX))
Expand All @@ -133,6 +143,7 @@ def parse_marker(body: str | None) -> dict[str, Any]:


def replace_marker(body: str | None, repo: str, workflow: str, seen: set[str]) -> str:
"""Insert or replace the hidden IssueOps marker in an issue body."""
body = body or ""
new_marker = marker(repo, workflow, seen)
start = body.find(MARKER_PREFIX)
Expand All @@ -143,10 +154,12 @@ def replace_marker(body: str | None, repo: str, workflow: str, seen: set[str]) -


def title(finding: dict[str, Any]) -> str:
"""Build the canonical issue title for one repository workflow failure."""
return f"[security-failure] {finding['repo']}: {finding['workflow']}"


def summary(finding: dict[str, Any]) -> str:
"""Render the key run, job, branch, and PR facts for an issue body."""
prs = ", ".join(f"#{number}" for number in finding["pr_numbers"]) or "n/a"
rows = [
("Repository", f"`{finding['repo']}`"),
Expand All @@ -164,6 +177,7 @@ def summary(finding: dict[str, Any]) -> str:


def issue_body(finding: dict[str, Any], seen: set[str]) -> str:
"""Render the first issue body for a collected security workflow failure."""
owner = finding["repo"].split("/", 1)[0]
return "\n\n".join(
[
Expand All @@ -176,4 +190,5 @@ def issue_body(finding: dict[str, Any], seen: set[str]) -> str:


def issue_comment(finding: dict[str, Any]) -> str:
"""Render a follow-up comment for a newly observed failure on an issue."""
return "\n\n".join(["New security workflow failure detected.", summary(finding), f"```text\n{finding['snippet']}\n```"])
17 changes: 17 additions & 0 deletions appguardrail_core/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ def render_fix_pack(


def _launch_posture(blockers: list[dict[str, Any]]) -> str:
"""Summarize whether blocker severity permits launch."""
if any(finding["severity"] == "CRITICAL" for finding in blockers):
return "Hold pending critical remediation"
if blockers:
Expand All @@ -366,6 +367,7 @@ def _launch_posture(blockers: list[dict[str, Any]]) -> str:


def _summary_table(findings: list[dict[str, Any]]) -> list[str]:
"""Render the buyer diligence summary table."""
rows = [
"| ID | Severity | Category | Location | References |",
"|---|---|---|---|---|",
Expand All @@ -385,6 +387,7 @@ def _summary_table(findings: list[dict[str, Any]]) -> list[str]:


def _finding_detail(index: int, finding: dict[str, Any]) -> list[str]:
"""Render one detailed buyer diligence finding section."""
references = ", ".join(finding["references"] or finding["owasp"] or finding["cwe"])
return [
f"### BD-{index:03d}: {_short_title(finding['message'])}",
Expand All @@ -410,6 +413,7 @@ def _finding_detail(index: int, finding: dict[str, Any]) -> list[str]:


def _short_title(message: str, max_len: int = 84) -> str:
"""Return a compact report heading derived from a finding message."""
title = message.split(".", 1)[0].strip() or "Security finding"
if len(title) <= max_len:
return title
Expand All @@ -426,6 +430,7 @@ def _prepare_report(
list[dict[str, Any]],
str,
]:
"""Normalize findings and compute shared report metadata."""
context = context or ReportContext()
normalized = [normalize_finding(finding) for finding in findings]
normalized.sort(key=finding_sort_key)
Expand All @@ -438,6 +443,7 @@ def _prepare_report(


def _founder_status(blockers: list[dict[str, Any]]) -> str:
"""Return the founder-facing launch status label."""
if any(finding["severity"] == "CRITICAL" for finding in blockers):
return "Not ready for public launch"
if blockers:
Expand All @@ -446,6 +452,7 @@ def _founder_status(blockers: list[dict[str, Any]]) -> str:


def _founder_finding(index: int, finding: dict[str, Any]) -> list[str]:
"""Render one founder-friendly finding section."""
return [
f"### Finding {index}: {_short_title(finding['message'])}",
"",
Expand All @@ -465,6 +472,7 @@ def _founder_finding(index: int, finding: dict[str, Any]) -> list[str]:


def _plain_risk(finding: dict[str, Any]) -> str:
"""Translate severity into plain-language business risk."""
severity = finding["severity"]
if severity == "CRITICAL":
return "This can expose sensitive data, credentials, money movement, or remote execution risk if reachable in production."
Expand All @@ -476,6 +484,7 @@ def _plain_risk(finding: dict[str, Any]) -> str:


def _fix_prompt(finding: dict[str, Any]) -> str:
"""Render a focused remediation prompt for one finding."""
return "\n".join(
[
f"Fix AppGuardrail finding `{finding['rule_id']}` in `{finding['file']}:{finding['line']}`.",
Expand All @@ -491,6 +500,7 @@ def _fix_prompt(finding: dict[str, Any]) -> str:
def _next_steps(
findings: list[dict[str, Any]], blockers: list[dict[str, Any]]
) -> list[str]:
"""Choose the next recommended operator actions for a report."""
if not findings:
return ["1. Re-run AppGuardrail with current production-bound code."]
steps = []
Expand All @@ -509,6 +519,7 @@ def _next_steps(


def _agency_recommendation(blockers: list[dict[str, Any]]) -> str:
"""Return the agency-facing launch recommendation."""
if any(finding["severity"] == "CRITICAL" for finding in blockers):
return "Hold pending critical fixes"
if blockers:
Expand All @@ -519,6 +530,7 @@ def _agency_recommendation(blockers: list[dict[str, Any]]) -> str:
def _agency_severity_section(
severity: str, findings: list[dict[str, Any]]
) -> list[str]:
"""Render all agency report findings for one severity bucket."""
heading = severity.title() if severity != "INFO" else "Informational"
lines = [f"### {heading} Findings", ""]
if not findings:
Expand Down Expand Up @@ -550,6 +562,7 @@ def _agency_severity_section(


def _priority_matrix(findings: list[dict[str, Any]]) -> list[str]:
"""Render the agency prioritization table."""
lines = [
"| ID | Title | Severity | Effort | Priority |",
"|---|---|---|---|---|",
Expand All @@ -569,6 +582,7 @@ def _priority_matrix(findings: list[dict[str, Any]]) -> list[str]:


def _priority_for(finding: dict[str, Any]) -> str:
"""Map a finding to a remediation priority label."""
if is_deploy_blocking(finding):
return "Immediate" if finding["severity"] == "CRITICAL" else "Before launch"
if finding["severity"] == "WARNING":
Expand All @@ -577,6 +591,7 @@ def _priority_for(finding: dict[str, Any]) -> str:


def _fix_item(index: int, finding: dict[str, Any]) -> list[str]:
"""Render one actionable fix-pack checklist item."""
return [
f"### [ ] FIX-{index:03d}: {_short_title(finding['message'])}",
"",
Expand All @@ -598,6 +613,7 @@ def _fix_item(index: int, finding: dict[str, Any]) -> list[str]:


def _fix_status_table(findings: list[dict[str, Any]]) -> list[str]:
"""Render the fix-pack status tracking table."""
lines = [
"| ID | Title | Severity | Status | Fixed By | Verified |",
"|---|---|---|---|---|---|",
Expand All @@ -614,4 +630,5 @@ def _fix_status_table(findings: list[dict[str, Any]]) -> list[str]:


def _references(finding: dict[str, Any]) -> str:
"""Return a comma-separated reference list for a finding."""
return ", ".join(finding["references"] or finding["owasp"] or finding["cwe"]) or "n/a"
20 changes: 13 additions & 7 deletions scanner/cli/appguardrail.py
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,11 @@ def _load_packaged_regex_rules():
"""


def _display_path(path: str | Path) -> str:
"""Return a stable, slash-separated path for CLI output and reports."""
return Path(path).as_posix()


# ---------------------------------------------------------------------------
# Command implementations
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1229,12 +1234,12 @@ def cmd_init(args):
existing = target_file.read_text()
if config["append_marker"] not in existing:
target_file.write_text(existing + "\n\n" + config["content"])
installed.append(f"{config['path']} (appended)")
installed.append(f"{_display_path(config['path'])} (appended)")
else:
skipped.append(str(config["path"]))
skipped.append(_display_path(config["path"]))
else:
target_file.write_text(config["content"])
installed.append(str(config["path"]))
installed.append(_display_path(config["path"]))
# Always create the checklist
checklist_file = project_root / "APPGUARDRAIL_CHECKLIST.md"

Expand Down Expand Up @@ -2139,15 +2144,15 @@ def _trivy_line(item: dict) -> int:
def _trivy_target(target: str, base_path: Path) -> str:
"""Normalize a Trivy target path relative to the scan base when possible."""
if not target:
return str(base_path)
return base_path.as_posix()
try:
path = Path(target)
if path.is_absolute():
root = base_path if base_path.is_dir() else base_path.parent
return str(path.relative_to(root))
return path.relative_to(root).as_posix()
except ValueError:
pass
return target
return Path(target).as_posix()


def _trivy_findings(report: dict, base_path: Path):
Expand Down Expand Up @@ -2678,6 +2683,7 @@ def _scan_file(file_path: Path, base_path: Path):
if base_path.is_file()
else file_path_str_cache
)
rel_path_for_filters = _display_path(rel_path_for_filters)
if not _path_allowed_by_rule(
rel_path_for_filters, include_paths, exclude_paths
):
Expand All @@ -2703,7 +2709,7 @@ def _scan_file(file_path: Path, base_path: Path):
else file_path_str_cache
)
rel_path_str = _sanitize_terminal_output(
str(rel_path_for_output)
_display_path(rel_path_for_output)
)

start_idx = match.start()
Expand Down
Loading
Loading