diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..67c47b5
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,15 @@
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ target-branch: "develop"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 5
+
+ - package-ecosystem: "pip"
+ directory: "/"
+ target-branch: "develop"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 5
diff --git a/.github/workflows/org-security-failure-collector.yml b/.github/workflows/org-security-failure-collector.yml
index 630811b..c4b7667 100644
--- a/.github/workflows/org-security-failure-collector.yml
+++ b/.github/workflows/org-security-failure-collector.yml
@@ -33,22 +33,37 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }}
LOOKBACK_HOURS: ${{ github.event_name == 'workflow_dispatch' && inputs.lookback_hours || '48' }}
+ ORG_SECURITY_FAILURE_APP_ID: ${{ vars.ORG_SECURITY_FAILURE_APP_ID }}
+ ORG_SECURITY_FAILURE_APP_PRIVATE_KEY: ${{ secrets.ORG_SECURITY_FAILURE_APP_PRIVATE_KEY }}
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - name: Check GitHub App collector configuration
+ id: app-config
+ run: |
+ set -euo pipefail
+ if [[ -n "$ORG_SECURITY_FAILURE_APP_ID" && -n "$ORG_SECURITY_FAILURE_APP_PRIVATE_KEY" ]]; then
+ echo "configured=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "configured=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::Skipping org security failure collection because ORG_SECURITY_FAILURE_APP_ID or ORG_SECURITY_FAILURE_APP_PRIVATE_KEY is not configured."
+ fi
+
- name: Create org installation token
+ if: steps.app-config.outputs.configured == 'true'
id: app-token
uses: actions/create-github-app-token@v3
with:
- app-id: ${{ vars.ORG_SECURITY_FAILURE_APP_ID }}
- private-key: ${{ secrets.ORG_SECURITY_FAILURE_APP_PRIVATE_KEY }}
+ app-id: ${{ env.ORG_SECURITY_FAILURE_APP_ID }}
+ private-key: ${{ env.ORG_SECURITY_FAILURE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
permission-actions: read
permission-checks: read
permission-issues: write
- name: Collect security workflow failures
+ if: steps.app-config.outputs.configured == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
diff --git a/.github/workflows/prepare-pypi-release.yml b/.github/workflows/prepare-pypi-release.yml
index 7ad1bbb..0725ec1 100644
--- a/.github/workflows/prepare-pypi-release.yml
+++ b/.github/workflows/prepare-pypi-release.yml
@@ -152,10 +152,68 @@ jobs:
- name: Validate package build
run: |
set -euo pipefail
- python -m pip install --disable-pip-version-check --no-cache-dir -r requirements-release.txt
+ python -m pip install --disable-pip-version-check --no-cache-dir --require-hashes -r requirements-release.txt
+ python -m pip_audit --local --vulnerability-service osv --progress-spinner off
python -m build
python -m twine check dist/*
+ - name: Generate release SBOM and provenance
+ run: |
+ set -euo pipefail
+ cyclonedx-py environment "$(python -c 'import sys; print(sys.executable)')" --output-reproducible --output-format JSON --output-file release-sbom.cdx.json
+ python - <<'PY'
+ import hashlib
+ import json
+ import os
+ import platform
+ import sys
+ from pathlib import Path
+
+ def file_record(path: Path) -> dict[str, object]:
+ return {
+ "path": str(path),
+ "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
+ "bytes": path.stat().st_size,
+ }
+
+ dist = [file_record(path) for path in sorted(Path("dist").glob("*")) if path.is_file()]
+ if not dist:
+ raise SystemExit("no package distributions were built")
+
+ payload = {
+ "schema": "https://contextualwisdomlab.github.io/appguardrail/release-provenance/v1",
+ "repository": os.environ.get("GITHUB_REPOSITORY"),
+ "workflow": os.environ.get("GITHUB_WORKFLOW"),
+ "run_id": os.environ.get("GITHUB_RUN_ID"),
+ "run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT"),
+ "ref": os.environ.get("GITHUB_REF"),
+ "sha": os.environ.get("GITHUB_SHA"),
+ "actor": os.environ.get("GITHUB_ACTOR"),
+ "python": {
+ "executable": sys.executable,
+ "version": sys.version,
+ "platform": platform.platform(),
+ },
+ "release_requirements": file_record(Path("requirements-release.txt")),
+ "dist": dist,
+ }
+ Path("release-provenance.json").write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+ PY
+
+ - name: Upload release supply-chain evidence
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: release-supply-chain-evidence
+ path: |
+ release-sbom.cdx.json
+ release-provenance.json
+ if-no-files-found: error
+ retention-days: 7
+
+ - name: Clean local release artifacts
+ run: |
+ rm -rf build dist *.egg-info release-sbom.cdx.json release-provenance.json
+
- name: Commit and push release branch
run: |
set -euo pipefail
diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml
index 8fb1767..4773744 100644
--- a/.github/workflows/publish-pypi.yml
+++ b/.github/workflows/publish-pypi.yml
@@ -25,7 +25,11 @@ jobs:
- name: Install build tooling
run: |
- python -m pip install --disable-pip-version-check --no-cache-dir -r requirements-release.txt
+ python -m pip install --disable-pip-version-check --no-cache-dir --require-hashes -r requirements-release.txt
+
+ - name: Audit release build tooling
+ run: |
+ python -m pip_audit --local --vulnerability-service osv --progress-spinner off
- name: Build package distributions
run: |
@@ -35,6 +39,59 @@ jobs:
run: |
python -m twine check dist/*
+ - name: Generate release SBOM and provenance
+ run: |
+ set -euo pipefail
+ cyclonedx-py environment "$(python -c 'import sys; print(sys.executable)')" --output-reproducible --output-format JSON --output-file release-sbom.cdx.json
+ python - <<'PY'
+ import hashlib
+ import json
+ import os
+ import platform
+ import sys
+ from pathlib import Path
+
+ def file_record(path: Path) -> dict[str, object]:
+ return {
+ "path": str(path),
+ "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
+ "bytes": path.stat().st_size,
+ }
+
+ dist = [file_record(path) for path in sorted(Path("dist").glob("*")) if path.is_file()]
+ if not dist:
+ raise SystemExit("no package distributions were built")
+
+ payload = {
+ "schema": "https://contextualwisdomlab.github.io/appguardrail/release-provenance/v1",
+ "repository": os.environ.get("GITHUB_REPOSITORY"),
+ "workflow": os.environ.get("GITHUB_WORKFLOW"),
+ "run_id": os.environ.get("GITHUB_RUN_ID"),
+ "run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT"),
+ "ref": os.environ.get("GITHUB_REF"),
+ "sha": os.environ.get("GITHUB_SHA"),
+ "actor": os.environ.get("GITHUB_ACTOR"),
+ "python": {
+ "executable": sys.executable,
+ "version": sys.version,
+ "platform": platform.platform(),
+ },
+ "release_requirements": file_record(Path("requirements-release.txt")),
+ "dist": dist,
+ }
+ Path("release-provenance.json").write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+ PY
+
+ - name: Upload release supply-chain evidence
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: release-supply-chain-evidence
+ path: |
+ release-sbom.cdx.json
+ release-provenance.json
+ if-no-files-found: error
+ retention-days: 7
+
- name: Upload package distributions
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
diff --git a/.github/workflows/scorecard-analysis.yml b/.github/workflows/scorecard-analysis.yml
new file mode 100644
index 0000000..0d1c2da
--- /dev/null
+++ b/.github/workflows/scorecard-analysis.yml
@@ -0,0 +1,38 @@
+name: Scorecard analysis
+
+on:
+ push:
+ branches: ["develop"]
+ schedule:
+ - cron: "30 1 * * 6"
+
+permissions: read-all
+
+jobs:
+ analysis:
+ name: Scorecard analysis
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write
+ id-token: write
+ contents: read
+ issues: read
+ pull-requests: read
+ checks: read
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Run analysis
+ uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
+ with:
+ results_file: results.sarif
+ results_format: sarif
+ publish_results: true
+
+ - name: Upload to code scanning
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
+ with:
+ sarif_file: results.sarif
diff --git a/.jules/bolt.md b/.jules/bolt.md
index c0c5641..49b1916 100644
--- a/.jules/bolt.md
+++ b/.jules/bolt.md
@@ -43,3 +43,7 @@
## 2026-07-01 - O(N*M) Line Counting Optimization
**Learning:** In `scanner/cli/appguardrail.py`, the `_scan_file` loop calculates line numbers by calling `count_newlines("\n", 0, start_idx)` for *every* regex match. In files with many matches, this repeatedly scans the string from the beginning, resulting in O(N*M) performance (where N is file length and M is matches). This is a massive bottleneck.
**Action:** Since `re.finditer` yields matches strictly in order, always calculate line numbers progressively using a tracking variable `current_line` and `current_pos`. Update `current_line += count_newlines("\n", current_pos, start_idx)`. This makes the line calculation strictly O(N), bringing up to a 15x speedup for files with many hits.
+
+## 2026-07-02 - Remove `re.search` fast-path pre-check
+**Learning:** Python's `re.finditer` evaluates lazily by allocating a lightweight C-level `ScannerObject`. Using `re.search` as a fast-path pre-check before `re.finditer` is an anti-pattern that addresses a non-existent bottleneck and degrades performance for matched paths by evaluating the regex twice.
+**Action:** Do not use `re.search` before `re.finditer` for optimization purposes.
diff --git a/README.md b/README.md
index 9e70415..b59a873 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,9 @@ appguardrail --help
Maintainers can prepare PyPI releases with GitHub Actions Bot and OpenCode
Agent. See [Release Automation](docs/release-automation.md).
+For the productization roadmap, see the
+[2B KRW sale readiness plan](docs/product/2026-07-02-2b-krw-sale-readiness-plan.md).
+
### Initialize security rules in your project
```bash
@@ -94,6 +97,9 @@ APPGUARDRAIL_TARGET_URL=https://your-authorized-test-host.example appguardrail s
# If CodeGraph is installed, prepare structural context for deeper review
appguardrail scan --codegraph .
+
+# Save normalized findings for report generation or dashboard ingestion
+appguardrail scan --findings-json reports/findings.json .
```
Detects:
@@ -115,6 +121,61 @@ documented rule fixtures until the lightweight engine grows structural matching.
Deploy-blocking counts focus on app code. Findings in docs, tests, examples,
and scanner fixtures stay visible but do not fail the deploy gate by default.
+### Generate reports from findings
+
+```bash
+appguardrail report buyer-diligence \
+ --findings reports/findings.json \
+ --out reports/buyer-diligence.md \
+ --app-name "Demo SaaS" \
+ --repository "ContextualWisdomLab/demo"
+
+appguardrail report founder-friendly \
+ --findings reports/findings.json \
+ --out reports/founder-security-review.md \
+ --app-name "Demo SaaS"
+
+appguardrail report agency \
+ --findings reports/findings.json \
+ --out reports/agency-security-review.md \
+ --app-name "Demo SaaS" \
+ --client-name "Demo Client" \
+ --reviewer "Demo Agency"
+
+appguardrail report fix-pack \
+ --findings reports/findings.json \
+ --out reports/fix-pack.md \
+ --based-on "pre-launch-review-001"
+```
+
+`appguardrail scan --findings-json` writes the normalized findings envelope that
+the report command accepts. You can also pass a raw JSON array of findings or
+any object with a `findings` array. Report types are:
+
+- `buyer-diligence`: buyer-readable launch posture and evidence checklist.
+- `founder-friendly`: plain-language summary for non-security founders.
+- `agency`: client-ready technical review and retest notes.
+- `fix-pack`: AI-ready remediation prompts and verification steps.
+
+Reports omit raw secrets and expand normalized metadata into launch posture,
+finding summaries, remediation, and verification checklists.
+
+### Generate an organization buyer evidence bundle
+
+```bash
+appguardrail org-bundle
+```
+
+This writes `appguardrail-buyer-evidence/` with:
+
+- `org-readiness.md`: buyer-readable organization readiness narrative.
+- `buyer-evidence.json`: machine-readable KPI payload.
+- `manifest.json`: source, timestamp, warning, repository, PR, and action bucket metadata.
+- `README.md`: how to use the generated evidence packet.
+
+Use `--owner`, `--bundle-dir`, `--repos-json`, or `--prs-json` only when you
+need a non-default organization, custom artifact path, or offline snapshot.
+
### Install continuous monitoring
```bash
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..b1d3190
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,28 @@
+# Security Policy
+
+## Reporting a Vulnerability
+
+Please do not report unpatched vulnerabilities through public GitHub issues.
+
+Preferred: use GitHub private vulnerability reporting for this repository:
+
+- https://github.com/ContextualWisdomLab/appguardrail/security/advisories/new
+
+If private reporting is unavailable, open a public issue that only asks for a secure disclosure channel. Do not include exploit details, secrets, personal data, or unreleased vulnerability information in a public issue.
+
+When reporting, include:
+
+- affected branch, tag, or commit
+- reproduction steps
+- impact assessment
+- proof-of-concept input or sanitized logs when needed for safe reproduction
+
+## Response Expectations
+
+- acknowledgement target: within 7 days
+- triage or status update target: within 30 days when a fix is feasible
+- coordinated disclosure preferred after a fix or mitigation is available
+
+## Safe Handling
+
+Do not send production credentials, private keys, customer data, or copyrighted third-party source documents in reports. Use synthetic fixtures and sanitized evidence whenever possible.
diff --git a/appguardrail_core/__init__.py b/appguardrail_core/__init__.py
new file mode 100644
index 0000000..6045faa
--- /dev/null
+++ b/appguardrail_core/__init__.py
@@ -0,0 +1,90 @@
+"""Reusable AppGuardrail core helpers."""
+
+from appguardrail_core.external import (
+ ExternalEngineDecision,
+ ExternalScanPlan,
+ build_external_scan_plan,
+)
+from appguardrail_core.findings import (
+ DEPLOY_BLOCKING_SEVERITIES,
+ NON_BLOCKING_CONTEXTS,
+ SEVERITIES,
+ finding_sort_key,
+ is_deploy_blocking,
+ normalize_finding,
+ normalize_findings,
+ safe_report_snippet,
+ severity_counts,
+)
+from appguardrail_core.language import (
+ StackProfile,
+ detect_language_axes,
+ detect_stack_profile,
+)
+from appguardrail_core.metrics import (
+ MetricResult,
+ SaleReadinessInputs,
+ SaleReadinessScore,
+ score_sale_readiness,
+)
+from appguardrail_core.org_intelligence import (
+ BuyerEvidenceMetric,
+ BuyerEvidencePack,
+ OrgInventory,
+ PullRequestGateSummary,
+ RepositoryGateSummary,
+ build_buyer_evidence_pack,
+ build_org_inventory,
+ buyer_evidence_pack_to_dict,
+ classify_pr_gate,
+ gate_action_bucket,
+ render_org_readiness_report,
+ summarize_pr_gates,
+)
+from appguardrail_core.rules import (
+ RuleMetadata,
+ build_rule_metadata,
+ extract_public_references,
+ validate_rule_metadata,
+)
+from appguardrail_core.reports import ReportContext, render_buyer_diligence_report
+
+__all__ = [
+ "DEPLOY_BLOCKING_SEVERITIES",
+ "ExternalEngineDecision",
+ "ExternalScanPlan",
+ "BuyerEvidenceMetric",
+ "BuyerEvidencePack",
+ "ReportContext",
+ "RuleMetadata",
+ "MetricResult",
+ "NON_BLOCKING_CONTEXTS",
+ "OrgInventory",
+ "PullRequestGateSummary",
+ "RepositoryGateSummary",
+ "SEVERITIES",
+ "SaleReadinessInputs",
+ "SaleReadinessScore",
+ "StackProfile",
+ "build_buyer_evidence_pack",
+ "build_external_scan_plan",
+ "build_org_inventory",
+ "build_rule_metadata",
+ "buyer_evidence_pack_to_dict",
+ "classify_pr_gate",
+ "detect_language_axes",
+ "detect_stack_profile",
+ "extract_public_references",
+ "finding_sort_key",
+ "gate_action_bucket",
+ "is_deploy_blocking",
+ "normalize_finding",
+ "normalize_findings",
+ "render_org_readiness_report",
+ "render_buyer_diligence_report",
+ "safe_report_snippet",
+ "score_sale_readiness",
+ "severity_counts",
+ "summarize_pr_gates",
+ "validate_rule_metadata",
+]
diff --git a/appguardrail_core/external.py b/appguardrail_core/external.py
new file mode 100644
index 0000000..7275186
--- /dev/null
+++ b/appguardrail_core/external.py
@@ -0,0 +1,172 @@
+"""External SAST/DAST engine planning for beginner-safe scans."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Callable, Iterable
+
+
+AvailabilityChecker = Callable[[str, tuple[str, ...]], object | None]
+
+
+@dataclass(frozen=True)
+class ExternalEngineDecision:
+ """One external engine's selection decision."""
+
+ name: str
+ display_name: str
+ should_run: bool
+ auto_selected: bool
+ forced: bool
+ auto_applicable: bool
+ available: bool
+ skip_reason: str | None
+ hint: str
+
+
+@dataclass(frozen=True)
+class ExternalScanPlan:
+ """External engine run plan shared by CLI, future API, and reports."""
+
+ trivy: ExternalEngineDecision
+ bandit: ExternalEngineDecision
+ ruff: ExternalEngineDecision
+ semgrep: ExternalEngineDecision
+ zap: ExternalEngineDecision
+
+ @property
+ def decisions(self) -> tuple[ExternalEngineDecision, ...]:
+ return (self.trivy, self.bandit, self.ruff, self.semgrep, self.zap)
+
+ @property
+ def selected_names(self) -> tuple[str, ...]:
+ return tuple(decision.name for decision in self.decisions if decision.should_run)
+
+
+def build_external_scan_plan(
+ languages: Iterable[str],
+ *,
+ external_mode: str = "auto",
+ force_trivy: bool = False,
+ force_bandit: bool = False,
+ force_ruff: bool = False,
+ force_semgrep: bool = False,
+ zap_baseline_url: str | None = None,
+ force_zap: bool = False,
+ tool_available: AvailabilityChecker | None = None,
+) -> ExternalScanPlan:
+ """Build a deterministic external engine plan from detected languages.
+
+ Forced engines run even when availability cannot be confirmed so the
+ concrete runner can fail loudly with its normal installation guidance.
+ Auto-selected engines run only when the relevant language is present and
+ the executable appears runnable.
+ """
+ language_set = {language.lower() for language in languages}
+ checker = tool_available or _missing_tool
+ auto_mode = external_mode == "auto"
+ semgrep_languages = {"java", "javascript", "python", "typescript", "web"}
+
+ return ExternalScanPlan(
+ trivy=_decision(
+ "trivy",
+ "Trivy FS",
+ forced=force_trivy,
+ auto_mode=False,
+ auto_applicable=False,
+ available=_check_if_needed(checker, force_trivy, "trivy"),
+ hint="Install Trivy or run without --trivy.",
+ ),
+ bandit=_decision(
+ "bandit",
+ "Bandit",
+ forced=force_bandit,
+ auto_mode=auto_mode,
+ auto_applicable="python" in language_set,
+ available=_check_if_needed(
+ checker, force_bandit or (auto_mode and "python" in language_set), "bandit"
+ ),
+ hint="Install Bandit or run without --bandit.",
+ ),
+ ruff=_decision(
+ "ruff",
+ "Ruff security rules",
+ forced=force_ruff,
+ auto_mode=auto_mode,
+ auto_applicable="python" in language_set,
+ available=_check_if_needed(
+ checker, force_ruff or (auto_mode and "python" in language_set), "ruff"
+ ),
+ hint="Install Ruff or run without --ruff.",
+ ),
+ semgrep=_decision(
+ "semgrep",
+ "Semgrep",
+ forced=force_semgrep,
+ auto_mode=auto_mode,
+ auto_applicable=bool(language_set & semgrep_languages),
+ available=_check_if_needed(
+ checker,
+ force_semgrep or (auto_mode and bool(language_set & semgrep_languages)),
+ "semgrep",
+ ),
+ hint="Install Semgrep correctly or run with --external off.",
+ ),
+ zap=_decision(
+ "zap",
+ "OWASP ZAP baseline",
+ forced=force_zap and bool(zap_baseline_url),
+ auto_mode=auto_mode,
+ auto_applicable=bool(zap_baseline_url),
+ available=_check_if_needed(
+ checker,
+ bool(zap_baseline_url) and (force_zap or auto_mode),
+ "zap-baseline.py",
+ ("-h",),
+ ),
+ hint="Install zap-baseline.py or run without --zap-baseline.",
+ ),
+ )
+
+
+def _decision(
+ name: str,
+ display_name: str,
+ *,
+ forced: bool,
+ auto_mode: bool,
+ auto_applicable: bool,
+ available: bool,
+ hint: str,
+) -> ExternalEngineDecision:
+ auto_selected = auto_mode and auto_applicable and available and not forced
+ should_run = forced or auto_selected
+ skip_reason = None
+ if auto_mode and auto_applicable and not should_run:
+ skip_reason = "executable not found or not runnable"
+ return ExternalEngineDecision(
+ name=name,
+ display_name=display_name,
+ should_run=should_run,
+ auto_selected=auto_selected,
+ forced=forced,
+ auto_applicable=auto_applicable,
+ available=available,
+ skip_reason=skip_reason,
+ hint=hint,
+ )
+
+
+def _check_if_needed(
+ checker: AvailabilityChecker,
+ needed: bool,
+ name: str,
+ version_args: tuple[str, ...] = ("--version",),
+) -> bool:
+ if not needed:
+ return False
+ return bool(checker(name, version_args))
+
+
+def _missing_tool(name: str, version_args: tuple[str, ...]) -> None:
+ return None
diff --git a/appguardrail_core/findings.py b/appguardrail_core/findings.py
new file mode 100644
index 0000000..7be60f1
--- /dev/null
+++ b/appguardrail_core/findings.py
@@ -0,0 +1,99 @@
+"""Normalized finding contract shared across AppGuardrail surfaces."""
+
+from __future__ import annotations
+
+from typing import Any, Iterable
+
+SEVERITIES = ("CRITICAL", "HIGH", "WARNING", "INFO")
+DEPLOY_BLOCKING_SEVERITIES = {"CRITICAL", "HIGH"}
+NON_BLOCKING_CONTEXTS = {"doc", "test", "example", "scanner-fixture"}
+
+_SEVERITY_ORDER = {severity: index for index, severity in enumerate(SEVERITIES)}
+
+
+def normalize_finding(
+ finding: dict[str, Any],
+ *,
+ snippet_max_len: int = 400,
+) -> dict[str, Any]:
+ """Return a normalized, report-safe AppGuardrail finding dictionary."""
+ normalized = dict(finding)
+ normalized["severity"] = str(normalized.get("severity") or "INFO").upper()
+ normalized["rule_id"] = str(normalized.get("rule_id") or "unknown-rule")
+ normalized["message"] = str(normalized.get("message") or "No message provided.")
+ normalized["file"] = str(normalized.get("file") or "n/a")
+ normalized["line"] = normalized.get("line") or 1
+ normalized["category"] = str(normalized.get("category") or "misconfig")
+ normalized["context"] = str(normalized.get("context") or "app-code")
+ normalized["references"] = _as_tuple(normalized.get("references"))
+ normalized["owasp"] = _as_tuple(normalized.get("owasp"))
+ normalized["cwe"] = _as_tuple(normalized.get("cwe"))
+ normalized["remediation"] = str(
+ normalized.get("remediation")
+ or normalized.get("fix_prompt")
+ or "Review and remediate this finding, then rerun AppGuardrail."
+ )
+ normalized["verification"] = str(
+ normalized.get("verification") or "Rerun AppGuardrail after remediation."
+ )
+ normalized["snippet"] = safe_report_snippet(
+ str(normalized.get("snippet") or ""), max_len=snippet_max_len
+ )
+ return normalized
+
+
+def normalize_findings(
+ findings: Iterable[dict[str, Any]],
+ *,
+ snippet_max_len: int = 400,
+) -> tuple[dict[str, Any], ...]:
+ """Normalize a finding collection into a stable tuple."""
+ return tuple(
+ normalize_finding(finding, snippet_max_len=snippet_max_len)
+ for finding in findings
+ )
+
+
+def severity_counts(findings: Iterable[dict[str, Any]]) -> dict[str, int]:
+ """Count normalized severities, folding unknown values into INFO."""
+ counts = {severity: 0 for severity in SEVERITIES}
+ for finding in findings:
+ severity = str(finding.get("severity") or "INFO").upper()
+ counts[severity if severity in counts else "INFO"] += 1
+ return counts
+
+
+def is_deploy_blocking(finding: dict[str, Any]) -> bool:
+ """Return whether a finding should fail a deploy gate."""
+ severity = str(finding.get("severity") or "INFO").upper()
+ context = str(finding.get("context") or "app-code")
+ return severity in DEPLOY_BLOCKING_SEVERITIES and context not in NON_BLOCKING_CONTEXTS
+
+
+def finding_sort_key(finding: dict[str, Any]) -> tuple[int, str, str]:
+ """Sort by deploy-oriented severity, then category and rule id."""
+ severity = str(finding.get("severity") or "INFO").upper()
+ return (
+ _SEVERITY_ORDER.get(severity, len(SEVERITIES)),
+ str(finding.get("category") or "misconfig"),
+ str(finding.get("rule_id") or "unknown-rule"),
+ )
+
+
+def safe_report_snippet(snippet: str, max_len: int = 400) -> str:
+ """Trim report evidence without carrying oversized raw snippets."""
+ snippet = snippet.replace("\r\n", "\n").replace("\r", "\n").strip()
+ if len(snippet) <= max_len:
+ return snippet
+ return snippet[:max_len].rstrip() + "\n...[truncated]"
+
+
+def _as_tuple(value: Any) -> tuple[str, ...]:
+ if not value:
+ return ()
+ if isinstance(value, str):
+ return (value,)
+ try:
+ return tuple(str(item) for item in value)
+ except TypeError:
+ return (str(value),)
diff --git a/appguardrail_core/issueops.py b/appguardrail_core/issueops.py
new file mode 100644
index 0000000..d8ca3f5
--- /dev/null
+++ b/appguardrail_core/issueops.py
@@ -0,0 +1,179 @@
+"""Reusable IssueOps helpers for security workflow failure handling."""
+
+from __future__ import annotations
+
+import json
+import re
+from typing import Any
+
+FAILURES = {"failure", "cancelled", "timed_out", "action_required"}
+SECURITY_TERMS = ("strix", "opencode", "appguardrail", "trivy", "codeql", "security process")
+MARKER_PREFIX = ""
+DEFAULT_MAX_LOG_CHARS = 30_000
+DEFAULT_MAX_LOG_LINES = 200
+
+ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
+TS_RE = re.compile(r"^\ufeff?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s*")
+SECRET_RE = [
+ re.compile(r"(?i)(authorization:\s*(?:bearer|token)\s+)[^\s]+"),
+ re.compile(r"(?i)\b((?:api[_-]?key|token|secret|password|private[_-]?key)\s*[:=]\s*)['\"]?[^'\"\s]+"),
+ re.compile(r"\b(?:gh[opsu]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]+|sk-[A-Za-z0-9]{20,})\b"),
+ re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"),
+]
+PRIMARY_LOG_RE = [
+ re.compile(pattern, re.IGNORECASE)
+ for pattern in (
+ r"^\s*::error::",
+ r"traceback",
+ r"vuln-",
+ r"\bcritical\b",
+ r"\bhigh\b",
+ r"ratelimiterror",
+ r"unable to map strix findings",
+ r"\btimeout\b|\btimed out\b",
+ )
+]
+FALLBACK_LOG_RE = [re.compile(r"\bfailed\b|\berror\b|\bfatal\b", re.IGNORECASE)]
+
+
+def is_failure(conclusion: str | None) -> bool:
+ return (conclusion or "").lower() in FAILURES
+
+
+def is_security_name(*names: str | None) -> bool:
+ 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]:
+ 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:
+ value = re.sub(r"[^A-Za-z0-9._:-]+", "-", value.strip()).strip("-")
+ return value[:45] or "unknown"
+
+
+def redact(log: str) -> str:
+ 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:
+ text = regex.sub(lambda match: f"{match.group(1)}[REDACTED]" if match.lastindex else "[REDACTED]", text)
+ return text
+
+
+def log_ranges(lines: list[str], patterns: list[re.Pattern[str]]) -> list[tuple[int, int]]:
+ return [
+ (max(0, index - 2), min(len(lines), index + 9))
+ for index, line in enumerate(lines)
+ if any(pattern.search(line) for pattern in patterns)
+ ]
+
+
+def compress_log(log: str, max_lines: int = DEFAULT_MAX_LOG_LINES, max_chars: int = DEFAULT_MAX_LOG_CHARS) -> str:
+ lines = redact(log).splitlines()
+ if not lines:
+ return "(no job log returned)"
+ ranges = log_ranges(lines, PRIMARY_LOG_RE) or log_ranges(lines, FALLBACK_LOG_RE)
+ if not ranges:
+ selected = lines[-max_lines:]
+ else:
+ merged: list[tuple[int, int]] = []
+ for start, end in sorted(ranges):
+ if merged and start <= merged[-1][1]:
+ merged[-1] = (merged[-1][0], max(merged[-1][1], end))
+ else:
+ merged.append((start, end))
+ chosen: list[tuple[int, int]] = []
+ count = 0
+ for start, end in reversed(merged):
+ chosen.append((start, end))
+ count += end - start + (1 if len(chosen) > 1 else 0)
+ if count >= max_lines:
+ break
+ selected = []
+ for start, end in sorted(chosen):
+ if selected:
+ selected.append("...")
+ selected.extend(lines[start:end])
+ if len(selected) >= max_lines:
+ break
+ selected = selected[:max_lines]
+ snippet = "\n".join(selected)
+ if len(snippet) > max_chars:
+ snippet = snippet[:max_chars].rstrip() + "\n...[truncated]"
+ if len(lines) > len(selected):
+ snippet += "\n...[compressed]"
+ return snippet
+
+
+def seen_key(finding: dict[str, Any]) -> str:
+ return f"{finding['run_id']}:{finding['job_id']}"
+
+
+def marker(repo: str, workflow: str, seen: set[str]) -> str:
+ 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]:
+ body = body or ""
+ start = body.find(MARKER_PREFIX)
+ end = body.find(MARKER_SUFFIX, start + len(MARKER_PREFIX))
+ if start == -1 or end == -1:
+ return {"seen": []}
+ try:
+ return json.loads(body[start + len(MARKER_PREFIX):end].strip())
+ except json.JSONDecodeError:
+ return {"seen": []}
+
+
+def replace_marker(body: str | None, repo: str, workflow: str, seen: set[str]) -> str:
+ body = body or ""
+ new_marker = marker(repo, workflow, seen)
+ start = body.find(MARKER_PREFIX)
+ end = body.find(MARKER_SUFFIX, start + len(MARKER_PREFIX))
+ if start == -1 or end == -1:
+ return f"{new_marker}\n\n{body}".strip()
+ return f"{body[:start]}{new_marker}{body[end + len(MARKER_SUFFIX):]}".strip()
+
+
+def title(finding: dict[str, Any]) -> str:
+ return f"[security-failure] {finding['repo']}: {finding['workflow']}"
+
+
+def summary(finding: dict[str, Any]) -> str:
+ prs = ", ".join(f"#{number}" for number in finding["pr_numbers"]) or "n/a"
+ rows = [
+ ("Repository", f"`{finding['repo']}`"),
+ ("Workflow", f"`{finding['workflow']}`"),
+ ("Job", f"`{finding['job_name']}`"),
+ ("Conclusion", f"`{finding['conclusion']}`"),
+ ("Branch", f"`{finding['branch']}`"),
+ ("Head SHA", f"`{finding['head_sha']}`"),
+ ("Event", f"`{finding['event']}`"),
+ ("PRs", prs),
+ ("Run", finding["run_url"]),
+ ("Job", finding["job_url"]),
+ ]
+ return "\n".join(f"- {key}: {value}" for key, value in rows)
+
+
+def issue_body(finding: dict[str, Any], seen: set[str]) -> str:
+ owner = finding["repo"].split("/", 1)[0]
+ return "\n\n".join(
+ [
+ marker(finding["repo"], finding["workflow"], seen),
+ f"Automated collection of security workflow failures across {owner}.",
+ summary(finding),
+ f"```text\n{finding['snippet']}\n```",
+ ]
+ )
+
+
+def issue_comment(finding: dict[str, Any]) -> str:
+ return "\n\n".join(["New security workflow failure detected.", summary(finding), f"```text\n{finding['snippet']}\n```"])
diff --git a/appguardrail_core/language.py b/appguardrail_core/language.py
new file mode 100644
index 0000000..ab760bb
--- /dev/null
+++ b/appguardrail_core/language.py
@@ -0,0 +1,250 @@
+"""Language and framework profile detection for zero-config scans."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Iterable
+
+LANGUAGE_EXTENSIONS = {
+ "javascript": [".js", ".jsx", ".mjs", ".cjs"],
+ "typescript": [".ts", ".tsx", ".mts", ".cts"],
+ "java": [".java"],
+ "python": [".py"],
+ "web": [".html", ".htm"],
+}
+
+LANGUAGE_BY_EXTENSION = {
+ extension: language
+ for language, extensions in LANGUAGE_EXTENSIONS.items()
+ for extension in extensions
+}
+
+PYTHON_MANIFESTS = {"pyproject.toml", "requirements.txt", "Pipfile", "poetry.lock"}
+JAVA_MANIFESTS = {
+ "pom.xml",
+ "build.gradle",
+ "build.gradle.kts",
+ "settings.gradle",
+ "gradle.lockfile",
+}
+NODE_MANIFESTS = {
+ "package.json",
+ "package-lock.json",
+ "pnpm-lock.yaml",
+ "yarn.lock",
+ "tsconfig.json",
+}
+
+PYTHON_WEB_MARKERS = {
+ "django",
+ "fastapi",
+ "flask",
+ "jinja2",
+ "pydantic",
+ "pyyaml",
+ "requests",
+ "sqlalchemy",
+ "starlette",
+}
+JAVA_WEB_MARKERS = {
+ "spring-boot",
+ "spring-security",
+ "springframework",
+ "servlet",
+ "jackson",
+ "jjwt",
+ "auth0",
+}
+NODE_WEB_MARKERS = {
+ "express",
+ "next",
+ "next.js",
+ "nestjs",
+ "react",
+ "vite",
+ "cors",
+ "helmet",
+ "jsonwebtoken",
+ "stripe",
+ "firebase",
+ "supabase",
+}
+
+WEB_SIGNAL_DIRS = {"app", "api", "pages", "routes", "templates", "views", "public"}
+MANIFEST_NAMES = PYTHON_MANIFESTS | JAVA_MANIFESTS | NODE_MANIFESTS
+
+
+@dataclass(frozen=True)
+class StackProfile:
+ """Beginner-facing scan profile inferred from repository files."""
+
+ id: str
+ display_name: str
+ languages: tuple[str, ...]
+ frameworks: tuple[str, ...]
+ signals: tuple[str, ...]
+ external_tools: tuple[str, ...]
+ zap_recommended: bool
+ beginner_summary: str
+ next_steps: tuple[str, ...]
+
+
+def detect_language_axes(files: Iterable[str | Path]) -> set[str]:
+ """Return language axes found in a scan target without requiring user flags."""
+ languages: set[str] = set()
+ for file_path in files:
+ path = Path(file_path)
+ language = LANGUAGE_BY_EXTENSION.get(path.suffix.lower())
+ if language:
+ languages.add(language)
+ if path.name in PYTHON_MANIFESTS:
+ languages.add("python")
+ if path.name in JAVA_MANIFESTS:
+ languages.add("java")
+ if path.name in NODE_MANIFESTS:
+ languages.add("javascript")
+ if path.name == "tsconfig.json":
+ languages.add("typescript")
+ return languages
+
+
+def detect_stack_profile(files: Iterable[str | Path]) -> StackProfile:
+ """Infer the most helpful zero-config scan profile for beginner users."""
+ paths = [Path(file_path) for file_path in files]
+ languages = detect_language_axes(paths)
+ frameworks = _detect_framework_markers(paths)
+ signals = _detect_signals(paths, frameworks)
+
+ if "java" in languages and languages & {"javascript", "typescript"}:
+ profile_id = "java-node-typescript"
+ display_name = "Java + Node.js/TypeScript web stack"
+ summary = (
+ "Java service and Node.js/TypeScript web code detected; AppGuardrail "
+ "will combine backend, frontend, and cross-service checks."
+ )
+ next_steps = (
+ "Review cross-service auth, CORS, JWT, cookie, and webhook findings first.",
+ "Run Semgrep and Trivy when available for deeper Java and JavaScript coverage.",
+ )
+ elif "python" in languages and (
+ "web" in languages or frameworks & PYTHON_WEB_MARKERS
+ ):
+ profile_id = "python-web"
+ display_name = "Python web application"
+ summary = (
+ "Python web markers detected; AppGuardrail will prioritize web auth, "
+ "deserialization, TLS, CORS, rendering, and dependency review."
+ )
+ next_steps = (
+ "Review critical/high findings before deployment.",
+ "Install Bandit, Ruff, and Semgrep for deeper optional checks.",
+ )
+ elif "java" in languages:
+ profile_id = "java"
+ display_name = "Java application"
+ summary = (
+ "Java code or build files detected; AppGuardrail will prioritize Spring, "
+ "JWT, TLS, cookie, and deserialization checks."
+ )
+ next_steps = (
+ "Review Spring Security, JWT, and TLS findings first.",
+ "Run Semgrep and import CodeQL evidence when available.",
+ )
+ elif languages & {"javascript", "typescript"}:
+ profile_id = "node-typescript-web"
+ display_name = "Node.js/TypeScript web application"
+ summary = (
+ "Node.js or TypeScript web markers detected; AppGuardrail will prioritize "
+ "auth, CORS, client-secret, webhook, and browser sink checks."
+ )
+ next_steps = (
+ "Review client-exposed secrets, CORS, auth, and webhook findings first.",
+ "Run Semgrep and Trivy when available for deeper coverage.",
+ )
+ elif languages:
+ profile_id = "generic-code"
+ display_name = "Generic code scan"
+ summary = "Code files detected; AppGuardrail will run applicable built-in rules."
+ next_steps = ("Review critical/high findings before deployment.",)
+ else:
+ profile_id = "unknown"
+ display_name = "Unknown stack"
+ summary = "No known language axis was detected from scanned files."
+ next_steps = ("Confirm the scan path contains source code or manifests.",)
+
+ return StackProfile(
+ id=profile_id,
+ display_name=display_name,
+ languages=tuple(sorted(languages)),
+ frameworks=tuple(sorted(frameworks)),
+ signals=tuple(sorted(signals)),
+ external_tools=_external_tools_for(languages, profile_id),
+ zap_recommended=_is_web_reachable(languages, frameworks, paths),
+ beginner_summary=summary,
+ next_steps=next_steps,
+ )
+
+
+def _detect_framework_markers(paths: list[Path]) -> set[str]:
+ markers: set[str] = set()
+ for path in paths:
+ name = path.name
+ lowered_path = path.as_posix().lower()
+ if "templates/" in lowered_path or "/views/" in lowered_path:
+ markers.add("templates")
+ if name not in MANIFEST_NAMES:
+ continue
+ text = _read_manifest_text(path).lower()
+ for marker in PYTHON_WEB_MARKERS | JAVA_WEB_MARKERS | NODE_WEB_MARKERS:
+ if marker in text:
+ markers.add(marker)
+ return markers
+
+
+def _detect_signals(paths: list[Path], frameworks: set[str]) -> set[str]:
+ signals = set(frameworks)
+ for path in paths:
+ name = path.name
+ if name in MANIFEST_NAMES:
+ signals.add(name)
+ for part in path.parts:
+ if part.lower() in WEB_SIGNAL_DIRS:
+ signals.add(part.lower())
+ return signals
+
+
+def _read_manifest_text(path: Path, max_bytes: int = 128_000) -> str:
+ try:
+ with path.open("rb") as handle:
+ return handle.read(max_bytes).decode("utf-8", errors="ignore")
+ except OSError:
+ return ""
+
+
+def _external_tools_for(languages: set[str], profile_id: str) -> tuple[str, ...]:
+ tools: set[str] = set()
+ if "python" in languages:
+ tools.update({"bandit", "ruff", "semgrep"})
+ if languages & {"java", "javascript", "typescript", "web"}:
+ tools.add("semgrep")
+ if profile_id in {
+ "java",
+ "java-node-typescript",
+ "node-typescript-web",
+ "python-web",
+ }:
+ tools.add("trivy")
+ return tuple(sorted(tools))
+
+
+def _is_web_reachable(
+ languages: set[str], frameworks: set[str], paths: list[Path]
+) -> bool:
+ if "web" in languages:
+ return True
+ if frameworks & (
+ PYTHON_WEB_MARKERS | JAVA_WEB_MARKERS | NODE_WEB_MARKERS | {"templates"}
+ ):
+ return True
+ return any(part.lower() in WEB_SIGNAL_DIRS for path in paths for part in path.parts)
diff --git a/appguardrail_core/metrics.py b/appguardrail_core/metrics.py
new file mode 100644
index 0000000..0388034
--- /dev/null
+++ b/appguardrail_core/metrics.py
@@ -0,0 +1,197 @@
+"""Sale-readiness KPI scoring for AppGuardrail product reviews."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Callable
+
+
+@dataclass(frozen=True)
+class SaleReadinessInputs:
+ """Measured inputs used to judge 2B KRW sale-readiness progress."""
+
+ install_to_first_finding_minutes: float
+ zero_config_scan_rate: float
+ actionable_output_rate: float
+ fixture_precision_rate: float
+ duplicate_issue_suppression_rate: float
+ redaction_regression_pass_rate: float
+ optional_engine_fallback_clear: bool
+ pilot_organizations: int
+ active_repositories: int
+ recurring_failures_grouped: int
+ founder_reports_generated: int
+ buyer_diligence_exports: int
+
+
+@dataclass(frozen=True)
+class MetricResult:
+ """One KPI result with enough context for dashboards and reports."""
+
+ id: str
+ label: str
+ value: float | int | bool
+ target: str
+ passed: bool
+ pillar: str
+
+
+@dataclass(frozen=True)
+class SaleReadinessScore:
+ """Aggregate product-readiness score and unmet KPI list."""
+
+ status: str
+ passed: int
+ total: int
+ pass_rate: float
+ metrics: tuple[MetricResult, ...]
+
+ @property
+ def unmet(self) -> tuple[MetricResult, ...]:
+ return tuple(metric for metric in self.metrics if not metric.passed)
+
+
+def score_sale_readiness(inputs: SaleReadinessInputs) -> SaleReadinessScore:
+ """Score AppGuardrail against the current sale-readiness KPI contract."""
+ metrics = (
+ _metric(
+ "time_to_first_finding",
+ "Time from install to first useful finding",
+ inputs.install_to_first_finding_minutes,
+ "< 5 minutes",
+ lambda value: value < 5,
+ "activation",
+ ),
+ _metric(
+ "zero_config_scan_rate",
+ "First scans requiring no language/profile flags",
+ inputs.zero_config_scan_rate,
+ "> 95%",
+ lambda value: value > 0.95,
+ "activation",
+ ),
+ _metric(
+ "actionable_output_rate",
+ "Scans with an actionable next step",
+ inputs.actionable_output_rate,
+ "> 95%",
+ lambda value: value > 0.95,
+ "activation",
+ ),
+ _metric(
+ "fixture_precision_rate",
+ "Built-in fixture precision for deploy blockers",
+ inputs.fixture_precision_rate,
+ "> 90%",
+ lambda value: value > 0.90,
+ "quality",
+ ),
+ _metric(
+ "duplicate_issue_suppression_rate",
+ "Duplicate CI failure issue suppression on replay",
+ inputs.duplicate_issue_suppression_rate,
+ "> 99%",
+ lambda value: value > 0.99,
+ "quality",
+ ),
+ _metric(
+ "redaction_regression_pass_rate",
+ "Token/JWT/Authorization redaction regression pass rate",
+ inputs.redaction_regression_pass_rate,
+ "100%",
+ lambda value: value >= 1.0,
+ "quality",
+ ),
+ _metric(
+ "optional_engine_fallback_clear",
+ "External-engine fallback clarity",
+ inputs.optional_engine_fallback_clear,
+ "clear missing-tool output",
+ bool,
+ "quality",
+ ),
+ _metric(
+ "pilot_organizations",
+ "Pilot organizations or internal equivalents scanned weekly",
+ inputs.pilot_organizations,
+ ">= 3",
+ lambda value: value >= 3,
+ "commercial",
+ ),
+ _metric(
+ "active_repositories",
+ "Active repositories under monitoring",
+ inputs.active_repositories,
+ ">= 20",
+ lambda value: value >= 20,
+ "commercial",
+ ),
+ _metric(
+ "recurring_failures_grouped",
+ "Recurring security failures grouped into issues",
+ inputs.recurring_failures_grouped,
+ ">= 50",
+ lambda value: value >= 50,
+ "commercial",
+ ),
+ _metric(
+ "founder_reports_generated",
+ "Founder-friendly reports generated from real scans",
+ inputs.founder_reports_generated,
+ ">= 10",
+ lambda value: value >= 10,
+ "commercial",
+ ),
+ _metric(
+ "buyer_diligence_exports",
+ "Buyer-diligence exports generated without manual editing",
+ inputs.buyer_diligence_exports,
+ ">= 5",
+ lambda value: value >= 5,
+ "commercial",
+ ),
+ )
+ passed = sum(1 for metric in metrics if metric.passed)
+ total = len(metrics)
+ pass_rate = passed / total if total else 0.0
+ return SaleReadinessScore(
+ status=_status(pass_rate, metrics),
+ passed=passed,
+ total=total,
+ pass_rate=pass_rate,
+ metrics=metrics,
+ )
+
+
+def _metric(
+ id: str,
+ label: str,
+ value: float | int | bool,
+ target: str,
+ predicate: Callable[[float | int | bool], bool],
+ pillar: str,
+) -> MetricResult:
+ return MetricResult(
+ id=id,
+ label=label,
+ value=value,
+ target=target,
+ passed=predicate(value),
+ pillar=pillar,
+ )
+
+
+def _status(pass_rate: float, metrics: tuple[MetricResult, ...]) -> str:
+ critical_unmet = {
+ "time_to_first_finding",
+ "zero_config_scan_rate",
+ "fixture_precision_rate",
+ "redaction_regression_pass_rate",
+ "buyer_diligence_exports",
+ }
+ unmet_ids = {metric.id for metric in metrics if not metric.passed}
+ if pass_rate == 1.0:
+ return "sale-ready"
+ if pass_rate >= 0.75 and not (unmet_ids & critical_unmet):
+ return "pilot-ready"
+ return "not-ready"
diff --git a/appguardrail_core/org_bundle.py b/appguardrail_core/org_bundle.py
new file mode 100644
index 0000000..3634d07
--- /dev/null
+++ b/appguardrail_core/org_bundle.py
@@ -0,0 +1,321 @@
+"""Organization evidence bundle helpers shared by CLI and CI scripts."""
+
+from __future__ import annotations
+
+import json
+import shutil
+import subprocess
+import sys
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+from appguardrail_core.org_intelligence import (
+ OrgInventory,
+ PullRequestGateSummary,
+ build_buyer_evidence_pack,
+ build_org_inventory,
+ buyer_evidence_pack_to_dict,
+ render_org_readiness_report,
+ summarize_pr_gates,
+)
+
+REPO_FIELDS = "name,isFork,isPrivate,defaultBranchRef,url,description,visibility,primaryLanguage,pushedAt"
+PR_DETAIL_FIELDS = "number,title,updatedAt,isDraft,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,headRefName,baseRefName"
+
+
+class OrgBundleError(RuntimeError):
+ """Raised when an organization evidence bundle cannot be produced."""
+
+
+def load_json(path: str | Path) -> list[dict[str, Any]]:
+ """Load a JSON array used as repository or pull-request source data."""
+ try:
+ payload = json.loads(Path(path).read_text())
+ except OSError as exc:
+ raise OrgBundleError(f"Cannot read JSON source: {path}") from exc
+ except json.JSONDecodeError as exc:
+ raise OrgBundleError(f"JSON source is invalid: {exc}") from exc
+ if not isinstance(payload, list):
+ raise OrgBundleError(f"{path} must contain a JSON array")
+ return payload
+
+
+def annotate_missing_pr_repositories(
+ prs: list[dict[str, Any]],
+ repository: str,
+) -> list[dict[str, Any]]:
+ """Attach repository metadata to PR rows that do not already include it."""
+ annotated: list[dict[str, Any]] = []
+ for pull in prs:
+ item = dict(pull)
+ item.setdefault("repository", {"nameWithOwner": repository})
+ annotated.append(item)
+ return annotated
+
+
+def render_org_evidence(
+ repos: list[dict[str, Any]],
+ prs: list[dict[str, Any]],
+ *,
+ active_repository_target: int = 20,
+ generated_at: str | None = None,
+) -> tuple[str, str, dict[str, Any], OrgInventory, PullRequestGateSummary]:
+ """Build the markdown, JSON payload, inventory, and PR summary."""
+ inventory = build_org_inventory(
+ repos,
+ active_repository_target=active_repository_target,
+ )
+ pr_summary = summarize_pr_gates(prs)
+ generated = generated_at or datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
+ report = render_org_readiness_report(
+ inventory,
+ pr_summary,
+ generated_at=generated,
+ )
+ evidence_payload = buyer_evidence_pack_to_dict(
+ build_buyer_evidence_pack(inventory, pr_summary)
+ )
+ return generated, report, evidence_payload, inventory, pr_summary
+
+
+def write_bundle(
+ bundle_dir: Path,
+ *,
+ report: str,
+ evidence_payload: dict[str, Any],
+ inventory: OrgInventory,
+ pr_summary: PullRequestGateSummary,
+ generated_at: str,
+ owner: str,
+ repos_source: str | None,
+ prs_source: str | None,
+ prs_repository_override: str | None = None,
+ per_repo_pr_limit: int = 100,
+ active_repository_target: int = 20,
+ collection_warnings: list[str] | tuple[str, ...] = (),
+) -> dict[str, Any]:
+ """Write the buyer evidence bundle and return its manifest."""
+ artifacts = {
+ "org_readiness_markdown": "org-readiness.md",
+ "buyer_evidence_json": "buyer-evidence.json",
+ "manifest": "manifest.json",
+ "readme": "README.md",
+ }
+ manifest = bundle_manifest(
+ artifacts=artifacts,
+ evidence_payload=evidence_payload,
+ inventory=inventory,
+ pr_summary=pr_summary,
+ generated_at=generated_at,
+ owner=owner,
+ repos_source=repos_source,
+ prs_source=prs_source,
+ prs_repository_override=prs_repository_override,
+ per_repo_pr_limit=per_repo_pr_limit,
+ active_repository_target=active_repository_target,
+ collection_warnings=collection_warnings,
+ )
+ try:
+ bundle_dir.mkdir(parents=True, exist_ok=True)
+ (bundle_dir / artifacts["org_readiness_markdown"]).write_text(report)
+ write_json(bundle_dir / artifacts["buyer_evidence_json"], evidence_payload)
+ write_json(bundle_dir / artifacts["manifest"], manifest)
+ (bundle_dir / artifacts["readme"]).write_text(bundle_readme(manifest))
+ except OSError as exc:
+ raise OrgBundleError(f"Cannot write buyer evidence bundle: {bundle_dir}") from exc
+ return manifest
+
+
+def bundle_manifest(
+ *,
+ artifacts: dict[str, str],
+ evidence_payload: dict[str, Any],
+ inventory: OrgInventory,
+ pr_summary: PullRequestGateSummary,
+ generated_at: str,
+ owner: str,
+ repos_source: str | None,
+ prs_source: str | None,
+ prs_repository_override: str | None,
+ per_repo_pr_limit: int,
+ active_repository_target: int,
+ collection_warnings: list[str] | tuple[str, ...],
+) -> dict[str, Any]:
+ """Build manifest metadata for a buyer evidence bundle."""
+ return {
+ "generated_at": generated_at,
+ "owner": owner,
+ "collection_warnings": list(collection_warnings),
+ "source": {
+ "repositories": source_descriptor(
+ repos_source,
+ fallback=f"gh repo list {owner} --no-archived",
+ ),
+ "pull_requests": source_descriptor(
+ prs_source,
+ fallback=f"gh pr list per non-fork repository in {owner}",
+ ),
+ "prs_repository_override": prs_repository_override,
+ "per_repo_pr_limit": per_repo_pr_limit,
+ "active_repository_target": active_repository_target,
+ },
+ "artifacts": artifacts,
+ "summary": {
+ "total_repositories": inventory.total_repositories,
+ "nonfork_repositories": inventory.nonfork_repositories,
+ "fork_repositories": inventory.fork_repositories,
+ "private_repositories": inventory.private_repositories,
+ "supported_nonfork_repositories": inventory.supported_nonfork_repositories,
+ "open_pull_requests": pr_summary.total_pull_requests,
+ "gate_counts": dict(pr_summary.gate_counts),
+ "action_bucket_counts": dict(pr_summary.action_bucket_counts),
+ "buyer_evidence_status": evidence_payload["overall_status"],
+ },
+ }
+
+
+def source_descriptor(path: str | None, *, fallback: str) -> dict[str, str]:
+ """Describe whether evidence came from a local file or live GitHub CLI."""
+ if path:
+ return {"kind": "file", "value": path}
+ return {"kind": "github_cli", "value": fallback}
+
+
+def bundle_readme(manifest: dict[str, Any]) -> str:
+ """Render the beginner-readable bundle README."""
+ summary = manifest["summary"]
+ artifacts = manifest["artifacts"]
+ top_bucket = top_count(summary["action_bucket_counts"])
+ warnings = manifest["collection_warnings"]
+ warning_line = f"- Collection warnings: {len(warnings)}"
+ return "\n".join(
+ [
+ "# AppGuardrail Buyer Evidence Bundle",
+ "",
+ f"Generated: {manifest['generated_at']}",
+ f"Owner: {manifest['owner']}",
+ "",
+ "## Files",
+ "",
+ f"- {artifacts['org_readiness_markdown']}: human-readable org readiness report.",
+ f"- {artifacts['buyer_evidence_json']}: machine-readable diligence KPI payload.",
+ f"- {artifacts['manifest']}: source, artifact, and summary metadata.",
+ "",
+ "## Current Status",
+ "",
+ f"- Buyer evidence status: {summary['buyer_evidence_status']}",
+ f"- Open PRs analyzed: {summary['open_pull_requests']}",
+ f"- Largest action bucket: {top_bucket}",
+ warning_line,
+ "",
+ "## How To Use",
+ "",
+ "1. Start with org-readiness.md for the buyer-facing narrative.",
+ "2. Attach buyer-evidence.json to dashboards or diligence data rooms.",
+ "3. Use manifest.json to prove the source and generation context.",
+ "4. Regenerate the bundle after the 7-day execution plan changes the gates.",
+ "",
+ ]
+ )
+
+
+def top_count(counts: dict[str, int]) -> str:
+ """Return the largest count in a beginner-readable label."""
+ if not counts:
+ return "n/a (0)"
+ key, value = sorted(counts.items(), key=lambda item: (-item[1], item[0]))[0]
+ return f"{key} ({value})"
+
+
+def write_json(path: Path, payload: dict[str, Any]) -> None:
+ """Write a stable JSON document."""
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
+
+
+def gh_repo_list(owner: str) -> list[dict[str, Any]]:
+ """List organization repositories visible to the current gh token."""
+ return gh_json(
+ [
+ "repo",
+ "list",
+ owner,
+ "--no-archived",
+ "--limit",
+ "200",
+ "--json",
+ REPO_FIELDS,
+ ]
+ )
+
+
+def gh_pr_list(
+ owner: str,
+ repos: list[dict[str, Any]],
+ per_repo_limit: int,
+) -> tuple[list[dict[str, Any]], list[str]]:
+ """Collect open PRs per non-fork repository, preserving repo-level failures."""
+ pulls: list[dict[str, Any]] = []
+ warnings: list[str] = []
+ for repo in repos:
+ if repo.get("isFork"):
+ continue
+ repo_name = repo.get("name")
+ if not repo_name:
+ continue
+ full_name = f"{owner}/{repo_name}"
+ try:
+ repo_pulls = gh_json(
+ [
+ "pr",
+ "list",
+ "--repo",
+ full_name,
+ "--state",
+ "open",
+ "--limit",
+ str(per_repo_limit),
+ "--json",
+ PR_DETAIL_FIELDS,
+ ]
+ )
+ except subprocess.CalledProcessError as exc:
+ warning = f"Skipped PR collection for {full_name}: {gh_error_message(exc)}"
+ warnings.append(warning)
+ print(f"warning: {warning}", file=sys.stderr)
+ continue
+ for pull in repo_pulls:
+ pull["repository"] = {"nameWithOwner": full_name}
+ pulls.append(pull)
+ return pulls, warnings
+
+
+def gh_json(args: list[str]) -> list[dict[str, Any]]:
+ """Run a gh command that returns a JSON array."""
+ gh = shutil.which("gh")
+ if not gh:
+ raise OrgBundleError("gh CLI is required when JSON source files are not provided")
+ try:
+ result = subprocess.run( # noqa: S603 - fixed gh command with explicit argv.
+ [gh, *args],
+ check=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ timeout=120,
+ )
+ except subprocess.TimeoutExpired as exc:
+ raise OrgBundleError("gh command timed out") from exc
+ try:
+ payload = json.loads(result.stdout or "[]")
+ except json.JSONDecodeError as exc:
+ raise OrgBundleError("gh command returned invalid JSON") from exc
+ if not isinstance(payload, list):
+ raise OrgBundleError("gh command returned non-array JSON")
+ return payload
+
+
+def gh_error_message(exc: subprocess.CalledProcessError) -> str:
+ """Return a compact gh error message suitable for CLI output."""
+ message = (exc.stderr or exc.stdout or str(exc)).strip()
+ return " ".join(message.split())
diff --git a/appguardrail_core/org_intelligence.py b/appguardrail_core/org_intelligence.py
new file mode 100644
index 0000000..2185e49
--- /dev/null
+++ b/appguardrail_core/org_intelligence.py
@@ -0,0 +1,609 @@
+"""Organization-level repository and PR readiness summaries."""
+
+from __future__ import annotations
+
+from collections import Counter
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from typing import Any, Iterable, Mapping
+
+SUPPORTED_PRIMARY_LANGUAGES = {
+ "HTML",
+ "Java",
+ "JavaScript",
+ "Python",
+ "TypeScript",
+}
+
+EXTERNAL_FIRST_LANGUAGES = {
+ "C++",
+ "Kotlin",
+ "R",
+ "Rust",
+ "Shell",
+}
+
+TECHNICAL_CHECK_FAILURES = {
+ "action_required",
+ "cancelled",
+ "error",
+ "failure",
+ "failed",
+ "timed_out",
+}
+
+WAITING_CHECK_STATES = {
+ "",
+ "expected",
+ "in_progress",
+ "pending",
+ "queued",
+ "requested",
+ "waiting",
+}
+
+GATE_BUCKETS = {
+ "ci-failure": "ci-failure",
+ "draft": "needs-triage",
+ "external-queued": "external-wait",
+ "merge-ready": "merge-ready",
+ "needs-triage": "needs-triage",
+ "review-required": "external-wait",
+ "source-conflict": "source-work",
+ "source-review": "source-work",
+}
+
+
+@dataclass(frozen=True)
+class OrgInventory:
+ """Repository inventory facts used by product and diligence reports."""
+
+ total_repositories: int
+ nonfork_repositories: int
+ fork_repositories: int
+ private_repositories: int
+ supported_nonfork_repositories: int
+ unsupported_nonfork_languages: tuple[str, ...]
+ primary_language_counts: tuple[tuple[str, int], ...]
+ default_branch_counts: tuple[tuple[str, int], ...]
+ active_repository_target: int
+ active_repository_target_met: bool
+
+
+@dataclass(frozen=True)
+class RepositoryGateSummary:
+ """One repository's PR gates, normalized into buyer-readable buckets."""
+
+ repository: str
+ total: int
+ source_work: int
+ ci_failures: int
+ external_wait: int
+ merge_ready: int
+ needs_triage: int
+ gate_counts: tuple[tuple[str, int], ...]
+
+
+@dataclass(frozen=True)
+class PullRequestGateSummary:
+ """PR gate summary that separates source work from external waiting."""
+
+ total_pull_requests: int
+ gate_counts: tuple[tuple[str, int], ...]
+ action_bucket_counts: tuple[tuple[str, int], ...]
+ repository_counts: tuple[tuple[str, int], ...]
+ top_repositories: tuple[RepositoryGateSummary, ...]
+
+
+@dataclass(frozen=True)
+class BuyerEvidenceMetric:
+ """One due-diligence check with beginner-readable status and context."""
+
+ id: str
+ label: str
+ status: str
+ observed: str
+ target: str
+ detail: str
+
+
+@dataclass(frozen=True)
+class BuyerEvidencePack:
+ """Machine-readable buyer evidence derived from org readiness facts."""
+
+ overall_status: str
+ metrics: tuple[BuyerEvidenceMetric, ...]
+ seven_day_plan: tuple[str, ...]
+
+
+def build_org_inventory(
+ repos: Iterable[Mapping[str, Any]],
+ *,
+ active_repository_target: int = 20,
+) -> OrgInventory:
+ """Build a stable organization inventory from GitHub repo JSON."""
+ repo_list = list(repos)
+ nonforks = [repo for repo in repo_list if not _truthy(repo.get("isFork"))]
+ forks = [repo for repo in repo_list if _truthy(repo.get("isFork"))]
+ primary_languages = Counter(_primary_language(repo) for repo in repo_list)
+ default_branches = Counter(_default_branch(repo) for repo in repo_list)
+ unsupported = sorted(
+ {
+ _primary_language(repo)
+ for repo in nonforks
+ if _primary_language(repo) not in SUPPORTED_PRIMARY_LANGUAGES
+ and _primary_language(repo) != "Unknown"
+ }
+ )
+ supported_nonforks = sum(
+ 1
+ for repo in nonforks
+ if _primary_language(repo) in SUPPORTED_PRIMARY_LANGUAGES
+ )
+ return OrgInventory(
+ total_repositories=len(repo_list),
+ nonfork_repositories=len(nonforks),
+ fork_repositories=len(forks),
+ private_repositories=sum(1 for repo in repo_list if _truthy(repo.get("isPrivate"))),
+ supported_nonfork_repositories=supported_nonforks,
+ unsupported_nonfork_languages=tuple(unsupported),
+ primary_language_counts=_sorted_counts(primary_languages),
+ default_branch_counts=_sorted_counts(default_branches),
+ active_repository_target=active_repository_target,
+ active_repository_target_met=len(nonforks) >= active_repository_target,
+ )
+
+
+def summarize_pr_gates(
+ prs: Iterable[Mapping[str, Any]],
+ *,
+ top_repository_limit: int = 10,
+) -> PullRequestGateSummary:
+ """Summarize open PR gates by actionable source work versus external wait."""
+ pr_list = list(prs)
+ classified = [(pr, classify_pr_gate(pr)) for pr in pr_list]
+ gate_counts = Counter(gate for _, gate in classified)
+ action_counts = Counter(gate_action_bucket(gate) for _, gate in classified)
+ repository_counts = Counter(_pr_repository(pr) for pr in pr_list)
+ return PullRequestGateSummary(
+ total_pull_requests=len(pr_list),
+ gate_counts=_sorted_counts(gate_counts),
+ action_bucket_counts=_sorted_counts(action_counts),
+ repository_counts=_sorted_counts(repository_counts),
+ top_repositories=_top_repositories(classified, top_repository_limit),
+ )
+
+
+def classify_pr_gate(pr: Mapping[str, Any]) -> str:
+ """Classify a PR into the gate that should drive the next action."""
+ if _truthy(pr.get("isDraft")):
+ return "draft"
+ review_decision = str(pr.get("reviewDecision") or "").lower()
+ mergeable = str(pr.get("mergeable") or "").lower()
+ merge_state = str(pr.get("mergeStateStatus") or "").lower()
+ check_states = _check_states(pr)
+ if mergeable == "conflicting" or merge_state == "dirty":
+ return "source-conflict"
+ if review_decision == "changes_requested":
+ return "source-review"
+ if check_states & TECHNICAL_CHECK_FAILURES:
+ return "ci-failure"
+ if check_states and check_states <= WAITING_CHECK_STATES:
+ return "external-queued"
+ if review_decision == "review_required":
+ return "review-required"
+ if mergeable == "mergeable" and merge_state in {"clean", "has_hooks", "unstable"}:
+ return "merge-ready"
+ return "needs-triage"
+
+
+def gate_action_bucket(gate: str) -> str:
+ """Map a detailed PR gate to the action bucket shown to beginners."""
+ return GATE_BUCKETS.get(gate, "needs-triage")
+
+
+def build_buyer_evidence_pack(
+ inventory: OrgInventory,
+ pr_summary: PullRequestGateSummary,
+) -> BuyerEvidencePack:
+ """Build pass/warn/fail evidence that can be exported for diligence."""
+ action_counts = dict(pr_summary.action_bucket_counts)
+ total_prs = pr_summary.total_pull_requests
+ source_work = action_counts.get("source-work", 0)
+ ci_failures = action_counts.get("ci-failure", 0)
+ supported_ratio = (
+ inventory.supported_nonfork_repositories / inventory.nonfork_repositories
+ if inventory.nonfork_repositories
+ else 0.0
+ )
+ source_ratio = source_work / total_prs if total_prs else 0.0
+ ci_ratio = ci_failures / total_prs if total_prs else 0.0
+ metrics = (
+ BuyerEvidenceMetric(
+ id="active_repository_coverage",
+ label="Active repository coverage",
+ status="pass" if inventory.active_repository_target_met else "fail",
+ observed=f"{inventory.nonfork_repositories}/{inventory.active_repository_target} non-fork repos",
+ target=f">= {inventory.active_repository_target} non-fork repos monitored",
+ detail="Shows there is enough live surface area for weekly buyer evidence.",
+ ),
+ BuyerEvidenceMetric(
+ id="supported_language_coverage",
+ label="Supported language coverage",
+ status=_threshold_status(supported_ratio, pass_at=0.80, warn_at=0.60),
+ observed=f"{inventory.supported_nonfork_repositories}/{inventory.nonfork_repositories} non-fork repos ({_percent(supported_ratio)})",
+ target=">= 80% pass, >= 60% warn",
+ detail="Unsupported languages should start with external engines before built-in rule promotion.",
+ ),
+ BuyerEvidenceMetric(
+ id="source_work_burden",
+ label="Source-work burden",
+ status=_inverse_threshold_status(source_ratio, pass_at=0.10, warn_at=0.35),
+ observed=f"{source_work}/{total_prs} PRs ({_percent(source_ratio)})",
+ target="<= 10% pass, <= 35% warn",
+ detail="Conflicts and change-requested PRs are product work, not review-process noise.",
+ ),
+ BuyerEvidenceMetric(
+ id="ci_failure_burden",
+ label="CI failure burden",
+ status=_inverse_threshold_status(ci_ratio, pass_at=0.05, warn_at=0.15),
+ observed=f"{ci_failures}/{total_prs} PRs ({_percent(ci_ratio)})",
+ target="<= 5% pass, <= 15% warn",
+ detail="CI failures should be routed into redacted, deduplicated IssueOps evidence.",
+ ),
+ BuyerEvidenceMetric(
+ id="reusable_evidence_export",
+ label="Reusable evidence export",
+ status="pass",
+ observed="Markdown report and JSON payload",
+ target="Human and machine-readable due-diligence output",
+ detail="Lets founders, reviewers, and future dashboards consume the same facts.",
+ ),
+ )
+ return BuyerEvidencePack(
+ overall_status=_overall_status(metrics),
+ metrics=metrics,
+ seven_day_plan=tuple(_seven_day_plan(inventory, pr_summary)),
+ )
+
+
+def buyer_evidence_pack_to_dict(pack: BuyerEvidencePack) -> dict[str, Any]:
+ """Convert the buyer evidence pack into stable JSON-friendly data."""
+ return {
+ "overall_status": pack.overall_status,
+ "metrics": [
+ {
+ "id": metric.id,
+ "label": metric.label,
+ "status": metric.status,
+ "observed": metric.observed,
+ "target": metric.target,
+ "detail": metric.detail,
+ }
+ for metric in pack.metrics
+ ],
+ "seven_day_plan": list(pack.seven_day_plan),
+ }
+
+
+def render_org_readiness_report(
+ inventory: OrgInventory,
+ pr_summary: PullRequestGateSummary,
+ *,
+ generated_at: str | None = None,
+) -> str:
+ """Render a buyer-readable organization readiness report."""
+ generated = generated_at or datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
+ evidence_pack = build_buyer_evidence_pack(inventory, pr_summary)
+ lines = [
+ "# AppGuardrail Organization Readiness Report",
+ "",
+ f"Generated: {generated}",
+ "",
+ "## Repository Inventory",
+ "",
+ f"- Total repositories: {inventory.total_repositories}",
+ f"- Non-fork repositories: {inventory.nonfork_repositories}",
+ f"- Fork repositories: {inventory.fork_repositories}",
+ f"- Private repositories: {inventory.private_repositories}",
+ f"- Supported non-fork primary languages: {inventory.supported_nonfork_repositories}",
+ f"- Active repository target: {inventory.active_repository_target}",
+ f"- Active repository target met: {_yes_no(inventory.active_repository_target_met)}",
+ "",
+ "### Primary Languages",
+ "",
+ *_table("Language", inventory.primary_language_counts),
+ "",
+ "### Default Branches",
+ "",
+ *_table("Branch", inventory.default_branch_counts),
+ "",
+ "## Open PR Gate Summary",
+ "",
+ f"- Open PRs analyzed: {pr_summary.total_pull_requests}",
+ "",
+ *_table("Gate", pr_summary.gate_counts),
+ "",
+ "## Action Buckets",
+ "",
+ *_table("Action", pr_summary.action_bucket_counts),
+ "",
+ "## Top Repositories By Actionable Work",
+ "",
+ *_repo_gate_table(pr_summary.top_repositories),
+ "",
+ "## First Actions",
+ "",
+ *_first_actions(pr_summary),
+ "",
+ "## Buyer Evidence Pack",
+ "",
+ f"- Overall status: {evidence_pack.overall_status}",
+ "",
+ "### Diligence KPI Checks",
+ "",
+ *_buyer_metric_table(evidence_pack.metrics),
+ "",
+ "### 7-Day Execution Plan",
+ "",
+ *[f"- {item}" for item in evidence_pack.seven_day_plan],
+ "",
+ "## Recommendations",
+ "",
+ *_recommendations(inventory, pr_summary),
+ "",
+ ]
+ return "\n".join(lines).rstrip() + "\n"
+
+
+def _recommendations(
+ inventory: OrgInventory, pr_summary: PullRequestGateSummary
+) -> list[str]:
+ recommendations: list[str] = []
+ if inventory.unsupported_nonfork_languages:
+ languages = ", ".join(inventory.unsupported_nonfork_languages)
+ recommendations.append(
+ f"- Unsupported non-fork primary languages: {languages}. Use external engines first and promote only repeated, precise patterns into built-in rules."
+ )
+ if inventory.active_repository_target_met:
+ recommendations.append(
+ "- The organization already meets the active repository count used by the sale-readiness KPI model."
+ )
+ else:
+ recommendations.append(
+ "- Active repository coverage is still below the sale-readiness KPI target."
+ )
+ gate_counts = dict(pr_summary.gate_counts)
+ if gate_counts.get("external-queued") or gate_counts.get("review-required"):
+ recommendations.append(
+ "- Queued checks or review waiting are tracked as external gates, not source defects."
+ )
+ if gate_counts.get("source-conflict") or gate_counts.get("source-review"):
+ recommendations.append(
+ "- Source conflicts and change-requested PRs need separate product work before merge."
+ )
+ if gate_counts.get("ci-failure"):
+ recommendations.append(
+ "- CI failures should be routed through AppGuardrail IssueOps with redacted logs and deduplicated issue comments."
+ )
+ return recommendations or ["- No immediate org readiness recommendations."]
+
+
+def _first_actions(pr_summary: PullRequestGateSummary) -> list[str]:
+ action_counts = dict(pr_summary.action_bucket_counts)
+ actions: list[str] = []
+ if action_counts.get("source-work"):
+ actions.append(
+ "- Fix source conflicts and change-requested PRs first; those are product work, not queue noise."
+ )
+ if action_counts.get("ci-failure"):
+ actions.append(
+ "- Route CI failures through AppGuardrail IssueOps so logs are redacted, compressed, and deduplicated."
+ )
+ if action_counts.get("external-wait"):
+ actions.append(
+ "- Track queued checks and review-required PRs as external gates until source work is needed."
+ )
+ if action_counts.get("merge-ready"):
+ actions.append(
+ "- Batch merge-ready PRs after confirming no unresolved review threads or source conflicts remain."
+ )
+ if action_counts.get("needs-triage"):
+ actions.append(
+ "- Triage unknown or draft PRs before treating them as buyer-ready delivery evidence."
+ )
+ return actions or ["- No PR actions were found in the supplied data."]
+
+
+def _seven_day_plan(
+ inventory: OrgInventory,
+ pr_summary: PullRequestGateSummary,
+) -> list[str]:
+ action_counts = dict(pr_summary.action_bucket_counts)
+ top_repo = pr_summary.top_repositories[0].repository if pr_summary.top_repositories else "the highest-risk repository"
+ plan: list[str] = []
+ if action_counts.get("source-work"):
+ plan.append(
+ f"Day 1-2: Clear source-work in {top_repo} first, then rerun the report."
+ )
+ if action_counts.get("ci-failure"):
+ plan.append(
+ "Day 3: Route CI failures through the security failure collector and attach redacted issue evidence."
+ )
+ if inventory.unsupported_nonfork_languages:
+ languages = ", ".join(inventory.unsupported_nonfork_languages)
+ plan.append(
+ f"Day 4: Cover {languages} with external-first scans before promoting built-in rules."
+ )
+ if action_counts.get("external-wait"):
+ plan.append(
+ "Day 5: Recheck queued checks and review waits; do not count them as source defects unless they fail."
+ )
+ if action_counts.get("merge-ready"):
+ plan.append(
+ "Day 6: Batch merge-ready PRs after unresolved review thread and source-conflict checks."
+ )
+ plan.append(
+ "Day 7: Regenerate Markdown and JSON evidence, archive it with the buyer diligence packet."
+ )
+ return plan
+
+
+def _primary_language(repo: Mapping[str, Any]) -> str:
+ language = repo.get("primaryLanguage")
+ if isinstance(language, Mapping):
+ return str(language.get("name") or "Unknown")
+ return str(language or "Unknown")
+
+
+def _default_branch(repo: Mapping[str, Any]) -> str:
+ branch = repo.get("defaultBranchRef")
+ if isinstance(branch, Mapping):
+ return str(branch.get("name") or "Unknown")
+ return str(branch or "Unknown")
+
+
+def _pr_repository(pr: Mapping[str, Any]) -> str:
+ repo = pr.get("repository")
+ if isinstance(repo, Mapping):
+ return str(repo.get("nameWithOwner") or repo.get("name") or "unknown")
+ return str(repo or "unknown")
+
+
+def _top_repositories(
+ classified: list[tuple[Mapping[str, Any], str]],
+ limit: int,
+) -> tuple[RepositoryGateSummary, ...]:
+ by_repo: dict[str, Counter[str]] = {}
+ for pr, gate in classified:
+ by_repo.setdefault(_pr_repository(pr), Counter())[gate] += 1
+ summaries = [
+ _repository_gate_summary(repository, gate_counts)
+ for repository, gate_counts in by_repo.items()
+ ]
+ summaries.sort(
+ key=lambda item: (
+ -item.source_work,
+ -item.ci_failures,
+ -item.needs_triage,
+ -item.total,
+ item.repository,
+ )
+ )
+ return tuple(summaries[: max(0, limit)])
+
+
+def _repository_gate_summary(
+ repository: str,
+ gate_counts: Counter[str],
+) -> RepositoryGateSummary:
+ bucket_counts = Counter(
+ {
+ "source-work": 0,
+ "ci-failure": 0,
+ "external-wait": 0,
+ "merge-ready": 0,
+ "needs-triage": 0,
+ }
+ )
+ for gate, count in gate_counts.items():
+ bucket_counts[gate_action_bucket(gate)] += count
+ return RepositoryGateSummary(
+ repository=repository,
+ total=sum(gate_counts.values()),
+ source_work=bucket_counts["source-work"],
+ ci_failures=bucket_counts["ci-failure"],
+ external_wait=bucket_counts["external-wait"],
+ merge_ready=bucket_counts["merge-ready"],
+ needs_triage=bucket_counts["needs-triage"],
+ gate_counts=_sorted_counts(gate_counts),
+ )
+
+
+def _check_states(pr: Mapping[str, Any]) -> set[str]:
+ states: set[str] = set()
+ for check in pr.get("statusCheckRollup") or ():
+ if not isinstance(check, Mapping):
+ continue
+ value = check.get("conclusion") or check.get("status") or check.get("state")
+ states.add(str(value or "").lower())
+ return states
+
+
+def _truthy(value: Any) -> bool:
+ return value is True or str(value).lower() in {"1", "true", "yes"}
+
+
+def _sorted_counts(counter: Counter[str]) -> tuple[tuple[str, int], ...]:
+ return tuple(sorted(counter.items(), key=lambda item: (-item[1], item[0])))
+
+
+def _table(label: str, rows: tuple[tuple[str, int], ...]) -> list[str]:
+ if not rows:
+ return [f"| {label} | Count |", "|---|---:|", "| n/a | 0 |"]
+ return [f"| {label} | Count |", "|---|---:|", *[f"| {key} | {count} |" for key, count in rows]]
+
+
+def _repo_gate_table(rows: tuple[RepositoryGateSummary, ...]) -> list[str]:
+ header = (
+ "| Repository | Open PRs | Source work | CI failures | External wait | Merge ready | Needs triage |",
+ "|---|---:|---:|---:|---:|---:|---:|",
+ )
+ if not rows:
+ return [*header, "| n/a | 0 | 0 | 0 | 0 | 0 | 0 |"]
+ return [
+ *header,
+ *[
+ f"| {row.repository} | {row.total} | {row.source_work} | {row.ci_failures} | {row.external_wait} | {row.merge_ready} | {row.needs_triage} |"
+ for row in rows
+ ],
+ ]
+
+
+def _buyer_metric_table(rows: tuple[BuyerEvidenceMetric, ...]) -> list[str]:
+ header = (
+ "| KPI | Status | Observed | Target |",
+ "|---|---|---|---|",
+ )
+ if not rows:
+ return [*header, "| n/a | fail | no evidence | evidence pack should include KPI rows |"]
+ return [
+ *header,
+ *[
+ f"| {row.label} | {row.status} | {row.observed} | {row.target} |"
+ for row in rows
+ ],
+ ]
+
+
+def _threshold_status(value: float, *, pass_at: float, warn_at: float) -> str:
+ if value >= pass_at:
+ return "pass"
+ if value >= warn_at:
+ return "warn"
+ return "fail"
+
+
+def _inverse_threshold_status(value: float, *, pass_at: float, warn_at: float) -> str:
+ if value <= pass_at:
+ return "pass"
+ if value <= warn_at:
+ return "warn"
+ return "fail"
+
+
+def _overall_status(metrics: tuple[BuyerEvidenceMetric, ...]) -> str:
+ statuses = {metric.status for metric in metrics}
+ if "fail" in statuses:
+ return "fail"
+ if "warn" in statuses:
+ return "warn"
+ return "pass"
+
+
+def _percent(value: float) -> str:
+ return f"{value:.1%}"
+
+
+def _yes_no(value: bool) -> str:
+ return "yes" if value else "no"
diff --git a/appguardrail_core/reports.py b/appguardrail_core/reports.py
new file mode 100644
index 0000000..0cf7693
--- /dev/null
+++ b/appguardrail_core/reports.py
@@ -0,0 +1,617 @@
+"""Report builders backed by normalized AppGuardrail findings."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from typing import Any, Iterable
+
+from appguardrail_core.findings import (
+ SEVERITIES,
+ finding_sort_key,
+ is_deploy_blocking,
+ normalize_finding,
+ severity_counts,
+)
+
+
+@dataclass(frozen=True)
+class ReportContext:
+ """Context shown in generated diligence reports."""
+
+ app_name: str = "AppGuardrail scan target"
+ repository: str = "n/a"
+ commit: str = "n/a"
+ generated_at: str = ""
+ scan_command: str = "appguardrail scan ."
+ scope: str = "Application source, configuration, and security workflow evidence."
+ client_name: str = "n/a"
+ reviewer: str = "AppGuardrail"
+ engagement_type: str = "Pre-launch review"
+ based_on: str = "AppGuardrail findings JSON"
+
+
+REPORT_TYPE_LABELS = {
+ "buyer-diligence": "Buyer diligence report",
+ "founder-friendly": "Founder-friendly report",
+ "agency": "Agency report",
+ "fix-pack": "Fix pack",
+}
+
+
+def supported_report_types() -> tuple[str, ...]:
+ """Return CLI-visible report types."""
+ return tuple(REPORT_TYPE_LABELS)
+
+
+def render_report(
+ report_type: str,
+ findings: Iterable[dict[str, Any]],
+ context: ReportContext | None = None,
+) -> str:
+ """Render any supported report type from normalized findings."""
+ renderers = {
+ "buyer-diligence": render_buyer_diligence_report,
+ "founder-friendly": render_founder_friendly_report,
+ "agency": render_agency_report,
+ "fix-pack": render_fix_pack,
+ }
+ try:
+ renderer = renderers[report_type]
+ except KeyError as exc:
+ raise ValueError(f"Unsupported report type: {report_type}") from exc
+ return renderer(findings, context)
+
+
+def render_buyer_diligence_report(
+ findings: Iterable[dict[str, Any]],
+ context: ReportContext | None = None,
+) -> str:
+ """Render a buyer-diligence markdown report from normalized findings."""
+ context = context or ReportContext()
+ normalized = [normalize_finding(finding) for finding in findings]
+ normalized.sort(key=finding_sort_key)
+ counts = severity_counts(normalized)
+ blockers = [finding for finding in normalized if is_deploy_blocking(finding)]
+
+ generated_at = context.generated_at or datetime.now(UTC).strftime(
+ "%Y-%m-%dT%H:%M:%SZ"
+ )
+ lines = [
+ "# AppGuardrail Buyer Diligence Report",
+ "",
+ f"**App:** {context.app_name}",
+ f"**Repository:** {context.repository}",
+ f"**Commit:** {context.commit}",
+ f"**Generated:** {generated_at}",
+ f"**Scan command:** `{context.scan_command}`",
+ "",
+ "## Executive Readout",
+ "",
+ f"**Launch posture:** {_launch_posture(blockers)}",
+ f"**Deploy-blocking findings:** {len(blockers)}",
+ "",
+ "| Severity | Count |",
+ "|---|---:|",
+ *[f"| {severity.title()} | {counts[severity]} |" for severity in SEVERITIES],
+ "",
+ "## Scope And Evidence Handling",
+ "",
+ f"- Scope: {context.scope}",
+ "- Raw customer code, secrets, tokens, and authorization values are not included.",
+ "- Findings are grouped by public security taxonomy where available.",
+ "- Suggested remediation is generated from AppGuardrail normalized metadata.",
+ "",
+ "## Findings Summary",
+ "",
+ ]
+
+ if normalized:
+ lines.extend(_summary_table(normalized))
+ else:
+ lines.append("No findings were provided for this report.")
+
+ lines.extend(["", "## Detailed Findings", ""])
+ if normalized:
+ for index, finding in enumerate(normalized, start=1):
+ lines.extend(_finding_detail(index, finding))
+ else:
+ lines.append("No detailed findings.")
+
+ lines.extend(
+ [
+ "",
+ "## Buyer Follow-Up Checklist",
+ "",
+ "- Confirm critical and high app-code findings are fixed or formally accepted.",
+ "- Re-run AppGuardrail and external engines used in the sale-readiness plan.",
+ "- Preserve GitHub Actions links and issue history for audit evidence.",
+ "- Confirm privacy retention, redaction policy, and authorized DAST targets.",
+ "",
+ ]
+ )
+ return "\n".join(lines).rstrip() + "\n"
+
+
+def render_founder_friendly_report(
+ findings: Iterable[dict[str, Any]],
+ context: ReportContext | None = None,
+) -> str:
+ """Render a plain-language launch readiness report for founders."""
+ context, normalized, counts, blockers, generated_at = _prepare_report(
+ findings, context
+ )
+ lines = [
+ "# AppGuardrail Security Review Report",
+ "",
+ f"**App:** {context.app_name}",
+ f"**Reviewed by:** {context.reviewer}",
+ f"**Date:** {generated_at}",
+ f"**Version / Commit:** {context.commit}",
+ "",
+ "## Summary",
+ "",
+ "| Severity | Count |",
+ "|---|---:|",
+ *[f"| {severity.title()} | {counts[severity]} |" for severity in SEVERITIES],
+ "",
+ f"**Overall Status:** {_founder_status(blockers)}",
+ "",
+ "## What We Checked",
+ "",
+ "- Authentication and protected routes",
+ "- Authorization and ownership checks",
+ "- Secrets and environment variables",
+ "- Database security and query construction",
+ "- File uploads and user-controlled paths",
+ "- Payments, webhooks, CORS, headers, and deployment configuration",
+ "",
+ "## Findings",
+ "",
+ ]
+ if normalized:
+ for index, finding in enumerate(normalized, start=1):
+ lines.extend(_founder_finding(index, finding))
+ else:
+ lines.append("No findings were provided for this report.")
+
+ lines.extend(
+ [
+ "",
+ "## What's Good",
+ "",
+ "- AppGuardrail evidence is normalized for repeatable review.",
+ "- Report snippets are trimmed to avoid carrying raw secrets or oversized logs.",
+ "- Public taxonomy references are included when findings provide them.",
+ "",
+ "## Recommended Next Steps",
+ "",
+ ]
+ )
+ lines.extend(_next_steps(normalized, blockers))
+ lines.extend(
+ [
+ "",
+ "## Scope And Limitations",
+ "",
+ f"- Scope: {context.scope}",
+ "- This report summarizes supplied AppGuardrail findings and does not replace a full penetration test.",
+ "- Third-party service internals and social engineering are outside this report unless separately reviewed.",
+ "",
+ ]
+ )
+ return "\n".join(lines).rstrip() + "\n"
+
+
+def render_agency_report(
+ findings: Iterable[dict[str, Any]],
+ context: ReportContext | None = None,
+) -> str:
+ """Render a client-ready agency security review report."""
+ context, normalized, counts, blockers, generated_at = _prepare_report(
+ findings, context
+ )
+ lines = [
+ "# AppGuardrail Agency Security Review Report",
+ "",
+ f"**Client:** {context.client_name}",
+ f"**Project:** {context.app_name}",
+ f"**Reviewed by:** {context.reviewer}",
+ f"**Date:** {generated_at}",
+ f"**Engagement type:** {context.engagement_type}",
+ "",
+ "## Executive Summary",
+ "",
+ f"**Critical findings requiring immediate action:** {counts['CRITICAL']}",
+ f"**High-severity findings:** {counts['HIGH']}",
+ f"**Total findings:** {len(normalized)}",
+ f"**Recommendation:** {_agency_recommendation(blockers)}",
+ "",
+ "## Methodology",
+ "",
+ "1. Static analysis of source, config, and workflow evidence.",
+ "2. External engine ingestion when Bandit, Ruff, Semgrep, Trivy, or ZAP findings are supplied.",
+ "3. Normalization to AppGuardrail severity, context, remediation, and verification metadata.",
+ "4. Client-ready prioritization by deploy-blocking risk.",
+ "",
+ "## Findings",
+ "",
+ ]
+ if normalized:
+ for severity in SEVERITIES:
+ severity_findings = [
+ finding for finding in normalized if finding["severity"] == severity
+ ]
+ lines.extend(_agency_severity_section(severity, severity_findings))
+ else:
+ lines.append("No findings were provided for this report.")
+
+ lines.extend(
+ [
+ "",
+ "## Remediation Priority Matrix",
+ "",
+ ]
+ )
+ if normalized:
+ lines.extend(_priority_matrix(normalized))
+ else:
+ lines.append("No remediation items.")
+
+ lines.extend(
+ [
+ "",
+ "## Retest Notes",
+ "",
+ "| ID | Status | Notes |",
+ "|---|---|---|",
+ ]
+ )
+ if normalized:
+ for index, finding in enumerate(normalized, start=1):
+ lines.append(f"| AG-{index:03d} | Pending | Rerun {finding['rule_id']} evidence. |")
+ else:
+ lines.append("| n/a | n/a | No findings. |")
+
+ lines.extend(
+ [
+ "",
+ "## Appendix A: Tools Used",
+ "",
+ f"- `{context.scan_command}`",
+ "- AppGuardrail normalized findings contract",
+ "",
+ "## Appendix B: Scope",
+ "",
+ f"- Repository: {context.repository}",
+ f"- Commit: {context.commit}",
+ f"- Scope: {context.scope}",
+ "",
+ ]
+ )
+ return "\n".join(lines).rstrip() + "\n"
+
+
+def render_fix_pack(
+ findings: Iterable[dict[str, Any]],
+ context: ReportContext | None = None,
+) -> str:
+ """Render AI-ready remediation prompts and verification steps."""
+ context, normalized, _counts, _blockers, generated_at = _prepare_report(
+ findings, context
+ )
+ actionable = [
+ finding
+ for finding in normalized
+ if finding["severity"] in {"CRITICAL", "HIGH", "WARNING"}
+ ]
+ lines = [
+ "# AppGuardrail Fix Pack",
+ "",
+ "A Fix Pack turns AppGuardrail findings into AI-ready remediation work items.",
+ "",
+ f"**App:** {context.app_name}",
+ f"**Fix Pack generated:** {generated_at}",
+ f"**Based on review:** {context.based_on}",
+ "",
+ "## How To Use This Fix Pack",
+ "",
+ "1. Work through each item from top to bottom: Critical, High, then Warning.",
+ "2. Copy the Fix Prompt into Claude Code, Cursor, Codex, or another coding assistant.",
+ "3. Apply the change in a branch, then run the Verification Test.",
+ "4. Re-run AppGuardrail before marking the item fixed.",
+ "",
+ "## Fix Items",
+ "",
+ ]
+ if actionable:
+ for index, finding in enumerate(actionable, start=1):
+ lines.extend(_fix_item(index, finding))
+ else:
+ lines.append("No critical, high, or warning findings were provided.")
+
+ lines.extend(
+ [
+ "",
+ "## Fix Pack Status",
+ "",
+ ]
+ )
+ if actionable:
+ lines.extend(_fix_status_table(actionable))
+ else:
+ lines.append("No open fix items.")
+
+ lines.extend(
+ [
+ "",
+ "## Post-Fix Checklist",
+ "",
+ "- Run `appguardrail scan .` and confirm no new deploy-blocking issues.",
+ "- Run the project test suite and confirm no regressions.",
+ "- Deploy to staging and repeat the listed verification tests.",
+ "- Keep the report and scan JSON as remediation evidence.",
+ "",
+ ]
+ )
+ return "\n".join(lines).rstrip() + "\n"
+
+
+def _launch_posture(blockers: list[dict[str, Any]]) -> str:
+ if any(finding["severity"] == "CRITICAL" for finding in blockers):
+ return "Hold pending critical remediation"
+ if blockers:
+ return "Conditional; resolve high findings before launch"
+ return "No deploy-blocking findings in supplied evidence"
+
+
+def _summary_table(findings: list[dict[str, Any]]) -> list[str]:
+ rows = [
+ "| ID | Severity | Category | Location | References |",
+ "|---|---|---|---|---|",
+ ]
+ for index, finding in enumerate(findings, start=1):
+ references = ", ".join(finding["references"] or finding["owasp"] or finding["cwe"])
+ rows.append(
+ "| {id} | {severity} | {category} | `{location}` | {references} |".format(
+ id=f"BD-{index:03d}",
+ severity=finding["severity"].title(),
+ category=finding["category"],
+ location=f"{finding['file']}:{finding['line']}",
+ references=references or "n/a",
+ )
+ )
+ return rows
+
+
+def _finding_detail(index: int, finding: dict[str, Any]) -> list[str]:
+ references = ", ".join(finding["references"] or finding["owasp"] or finding["cwe"])
+ return [
+ f"### BD-{index:03d}: {_short_title(finding['message'])}",
+ "",
+ f"- Severity: {finding['severity'].title()}",
+ f"- Rule: `{finding['rule_id']}`",
+ f"- Category: `{finding['category']}`",
+ f"- Context: `{finding['context']}`",
+ f"- Location: `{finding['file']}:{finding['line']}`",
+ f"- References: {references or 'n/a'}",
+ "",
+ "**Evidence:**",
+ "",
+ f"```text\n{finding['snippet'] or '(snippet unavailable)'}\n```",
+ "",
+ f"**Risk:** {finding['message']}",
+ "",
+ f"**Remediation:** {finding['remediation']}",
+ "",
+ f"**Verification:** {finding['verification']}",
+ "",
+ ]
+
+
+def _short_title(message: str, max_len: int = 84) -> str:
+ title = message.split(".", 1)[0].strip() or "Security finding"
+ if len(title) <= max_len:
+ return title
+ return title[: max_len - 3].rstrip() + "..."
+
+
+def _prepare_report(
+ findings: Iterable[dict[str, Any]],
+ context: ReportContext | None,
+) -> tuple[
+ ReportContext,
+ list[dict[str, Any]],
+ dict[str, int],
+ list[dict[str, Any]],
+ str,
+]:
+ context = context or ReportContext()
+ normalized = [normalize_finding(finding) for finding in findings]
+ normalized.sort(key=finding_sort_key)
+ counts = severity_counts(normalized)
+ blockers = [finding for finding in normalized if is_deploy_blocking(finding)]
+ generated_at = context.generated_at or datetime.now(UTC).strftime(
+ "%Y-%m-%dT%H:%M:%SZ"
+ )
+ return context, normalized, counts, blockers, generated_at
+
+
+def _founder_status(blockers: list[dict[str, Any]]) -> str:
+ if any(finding["severity"] == "CRITICAL" for finding in blockers):
+ return "Not ready for public launch"
+ if blockers:
+ return "Launch only after high-risk items are fixed"
+ return "Cleared for launch based on supplied findings"
+
+
+def _founder_finding(index: int, finding: dict[str, Any]) -> list[str]:
+ return [
+ f"### Finding {index}: {_short_title(finding['message'])}",
+ "",
+ f"**Severity:** {finding['severity'].title()}",
+ "",
+ f"**What we found:** {finding['message']}",
+ "",
+ f"**Why it matters:** {_plain_risk(finding)}",
+ "",
+ "**Fix prompt:**",
+ "",
+ f"```text\n{_fix_prompt(finding)}\n```",
+ "",
+ f"**How to verify the fix:** {finding['verification']}",
+ "",
+ ]
+
+
+def _plain_risk(finding: dict[str, Any]) -> str:
+ severity = finding["severity"]
+ if severity == "CRITICAL":
+ return "This can expose sensitive data, credentials, money movement, or remote execution risk if reachable in production."
+ if severity == "HIGH":
+ return "This is likely to become a launch blocker if the affected code is reachable by users or automated workflows."
+ if severity == "WARNING":
+ return "This may be safe in context, but it needs a deliberate review before launch."
+ return "This is useful context for hardening and buyer diligence."
+
+
+def _fix_prompt(finding: dict[str, Any]) -> str:
+ return "\n".join(
+ [
+ f"Fix AppGuardrail finding `{finding['rule_id']}` in `{finding['file']}:{finding['line']}`.",
+ "",
+ f"Problem: {finding['message']}",
+ f"Recommended remediation: {finding['remediation']}",
+ "",
+ f"After applying the fix, verify with: {finding['verification']}",
+ ]
+ )
+
+
+def _next_steps(
+ findings: list[dict[str, Any]], blockers: list[dict[str, Any]]
+) -> list[str]:
+ if not findings:
+ return ["1. Re-run AppGuardrail with current production-bound code."]
+ steps = []
+ if blockers:
+ first = blockers[0]
+ steps.append(
+ f"1. Fix `{first['rule_id']}` before launch because it is deploy-blocking."
+ )
+ steps.append("2. Re-run AppGuardrail and keep the findings JSON as evidence.")
+ steps.append("3. Review warning and info items before the next release.")
+ else:
+ steps.append("1. Keep this clean findings snapshot with release evidence.")
+ steps.append("2. Re-run AppGuardrail on every security-sensitive pull request.")
+ steps.append("3. Schedule periodic external engine checks for drift.")
+ return steps
+
+
+def _agency_recommendation(blockers: list[dict[str, Any]]) -> str:
+ if any(finding["severity"] == "CRITICAL" for finding in blockers):
+ return "Hold pending critical fixes"
+ if blockers:
+ return "Approved for launch only after high findings are resolved"
+ return "Cleared based on supplied AppGuardrail evidence"
+
+
+def _agency_severity_section(
+ severity: str, findings: list[dict[str, Any]]
+) -> list[str]:
+ heading = severity.title() if severity != "INFO" else "Informational"
+ lines = [f"### {heading} Findings", ""]
+ if not findings:
+ lines.append(f"No {heading.lower()} findings.")
+ lines.append("")
+ return lines
+ for finding in findings:
+ lines.extend(
+ [
+ f"#### {_short_title(finding['message'])}",
+ "",
+ "| Field | Value |",
+ "|---|---|",
+ f"| Severity | {finding['severity'].title()} |",
+ f"| Category | `{finding['category']}` |",
+ f"| Affected Component | `{finding['file']}:{finding['line']}` |",
+ f"| Rule | `{finding['rule_id']}` |",
+ f"| References | {_references(finding)} |",
+ "",
+ f"**Description:** {finding['message']}",
+ "",
+ f"**Remediation:** {finding['remediation']}",
+ "",
+ f"**Verification:** {finding['verification']}",
+ "",
+ ]
+ )
+ return lines
+
+
+def _priority_matrix(findings: list[dict[str, Any]]) -> list[str]:
+ lines = [
+ "| ID | Title | Severity | Effort | Priority |",
+ "|---|---|---|---|---|",
+ ]
+ for index, finding in enumerate(findings, start=1):
+ priority = _priority_for(finding)
+ lines.append(
+ "| {id} | {title} | {severity} | {effort} | {priority} |".format(
+ id=f"AG-{index:03d}",
+ title=_short_title(finding["message"], max_len=48),
+ severity=finding["severity"].title(),
+ effort="Review",
+ priority=priority,
+ )
+ )
+ return lines
+
+
+def _priority_for(finding: dict[str, Any]) -> str:
+ if is_deploy_blocking(finding):
+ return "Immediate" if finding["severity"] == "CRITICAL" else "Before launch"
+ if finding["severity"] == "WARNING":
+ return "Within 30 days"
+ return "Backlog"
+
+
+def _fix_item(index: int, finding: dict[str, Any]) -> list[str]:
+ return [
+ f"### [ ] FIX-{index:03d}: {_short_title(finding['message'])}",
+ "",
+ f"**Severity:** {finding['severity'].title()}",
+ "",
+ f"**Problem:** {finding['message']}",
+ "",
+ f"**Risk:** {_plain_risk(finding)}",
+ "",
+ "**Fix Prompt:**",
+ "",
+ f"```text\n{_fix_prompt(finding)}\n```",
+ "",
+ "**Verification Test:**",
+ "",
+ f"{finding['verification']}",
+ "",
+ ]
+
+
+def _fix_status_table(findings: list[dict[str, Any]]) -> list[str]:
+ lines = [
+ "| ID | Title | Severity | Status | Fixed By | Verified |",
+ "|---|---|---|---|---|---|",
+ ]
+ for index, finding in enumerate(findings, start=1):
+ lines.append(
+ "| {id} | {title} | {severity} | Open | | |".format(
+ id=f"FIX-{index:03d}",
+ title=_short_title(finding["message"], max_len=48),
+ severity=finding["severity"].title(),
+ )
+ )
+ return lines
+
+
+def _references(finding: dict[str, Any]) -> str:
+ return ", ".join(finding["references"] or finding["owasp"] or finding["cwe"]) or "n/a"
diff --git a/appguardrail_core/rules.py b/appguardrail_core/rules.py
new file mode 100644
index 0000000..87180a8
--- /dev/null
+++ b/appguardrail_core/rules.py
@@ -0,0 +1,148 @@
+"""Rule metadata helpers for buyer-friendly AppGuardrail findings."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Any
+
+REFERENCE_RE = re.compile(r"\[(OWASP [^\]]+|CWE-\d+[^\]]*|CVE-\d{4}-\d+[^\]]*)\]")
+
+CATEGORY_REFERENCE_DEFAULTS = {
+ "authz": ("OWASP A01:2021 - Broken Access Control", "CWE-862 - Missing Authorization"),
+ "dependency": ("OWASP A06:2021 - Vulnerable and Outdated Components",),
+ "injection": ("OWASP A03:2021 - Injection", "CWE-74 - Injection"),
+ "misconfig": ("OWASP A05:2021 - Security Misconfiguration",),
+ "payment": ("OWASP A08:2021 - Software and Data Integrity Failures",),
+ "secrets": (
+ "OWASP A07:2021 - Identification and Authentication Failures",
+ "CWE-798 - Use of Hard-coded Credentials",
+ ),
+ "storage": ("OWASP A01:2021 - Broken Access Control",),
+}
+
+SAMM_BY_CATEGORY = {
+ "authz": "Implementation / Secure Build",
+ "dependency": "Implementation / Secure Build",
+ "injection": "Implementation / Secure Build",
+ "misconfig": "Operations / Environment Management",
+ "payment": "Verification / Requirements-driven Testing",
+ "secrets": "Operations / Environment Management",
+ "storage": "Implementation / Secure Build",
+}
+
+REMEDIATION_BY_CATEGORY = {
+ "authz": (
+ "Require authentication and server-side authorization before returning "
+ "or mutating user-owned data."
+ ),
+ "dependency": (
+ "Upgrade the affected package or document a time-bound risk acceptance "
+ "when no fix exists."
+ ),
+ "injection": (
+ "Replace string-built execution or query paths with parameterized APIs "
+ "and strict allowlists."
+ ),
+ "misconfig": (
+ "Restore the secure default or document the production boundary that "
+ "makes the exception safe."
+ ),
+ "payment": (
+ "Validate payment events and prices server-side using provider "
+ "signatures and trusted product data."
+ ),
+ "secrets": (
+ "Remove the secret from source, rotate it, and load future values from "
+ "managed secret storage."
+ ),
+ "storage": "Enforce storage or database access controls with authenticated ownership policies.",
+}
+
+
+@dataclass(frozen=True)
+class RuleMetadata:
+ """Normalized rule metadata shared by CLI, reports, and future UI."""
+
+ rule_id: str
+ severity: str
+ category: str
+ source: str
+ references: tuple[str, ...]
+ owasp: tuple[str, ...]
+ cwe: tuple[str, ...]
+ samm_practice: str
+ remediation: str
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "rule_id": self.rule_id,
+ "severity": self.severity,
+ "category": self.category,
+ "source": self.source,
+ "references": self.references,
+ "owasp": self.owasp,
+ "cwe": self.cwe,
+ "samm_practice": self.samm_practice,
+ "remediation": self.remediation,
+ }
+
+
+def extract_public_references(message: str) -> tuple[str, ...]:
+ """Extract OWASP, CWE, and CVE references already embedded in rule copy."""
+ seen: list[str] = []
+ for match in REFERENCE_RE.finditer(message or ""):
+ reference = " ".join(match.group(1).split())
+ if reference not in seen:
+ seen.append(reference)
+ return tuple(seen)
+
+
+def build_rule_metadata(
+ rule_id: str,
+ severity: str,
+ message: str,
+ *,
+ category: str,
+ source: str = "appguardrail-rule",
+) -> RuleMetadata:
+ """Build a stable metadata envelope for a scanner finding."""
+ references = _merge_references(
+ extract_public_references(message),
+ CATEGORY_REFERENCE_DEFAULTS.get(category, ()),
+ )
+ return RuleMetadata(
+ rule_id=rule_id,
+ severity=severity,
+ category=category,
+ source=source,
+ references=references,
+ owasp=tuple(ref for ref in references if ref.startswith("OWASP ")),
+ cwe=tuple(ref for ref in references if ref.startswith("CWE-")),
+ samm_practice=SAMM_BY_CATEGORY.get(category, "Verification / Security Testing"),
+ remediation=REMEDIATION_BY_CATEGORY.get(
+ category,
+ "Review the finding, fix the unsafe pattern, and rerun AppGuardrail.",
+ ),
+ )
+
+
+def validate_rule_metadata(metadata: RuleMetadata | dict[str, Any]) -> list[str]:
+ """Return missing-field errors for rule metadata before it reaches reports."""
+ data = metadata.as_dict() if isinstance(metadata, RuleMetadata) else metadata
+ errors = []
+ for field in ("rule_id", "severity", "category", "references", "remediation"):
+ if not data.get(field):
+ errors.append(f"missing {field}")
+ if not data.get("owasp") and not data.get("cwe"):
+ errors.append("missing public taxonomy reference")
+ return errors
+
+
+def _merge_references(*groups: tuple[str, ...]) -> tuple[str, ...]:
+ merged: list[str] = []
+ for group in groups:
+ for reference in group:
+ if reference and reference not in merged:
+ merged.append(reference)
+ return tuple(merged)
diff --git a/docs/product/2026-07-02-2b-krw-sale-readiness-plan.md b/docs/product/2026-07-02-2b-krw-sale-readiness-plan.md
new file mode 100644
index 0000000..1db5a9f
--- /dev/null
+++ b/docs/product/2026-07-02-2b-krw-sale-readiness-plan.md
@@ -0,0 +1,514 @@
+# AppGuardrail 2B KRW Sale Readiness Plan
+
+Date: 2026-07-02
+Status: Active execution plan
+Audience: founders, maintainers, pilot buyers, acquisition diligence reviewers
+
+## Goal
+
+Raise AppGuardrail from an alpha CLI and rule pack into a product that can be
+credibly discussed as a 2B KRW acquisition target. The target is not a valuation
+claim. It is an execution standard: the repository must show productized IP,
+repeatable buyer value, beginner-safe onboarding, measurable risk reduction,
+and a credible path from open-source trust to paid deployment.
+
+Operational constraints:
+
+- Figma Code Connect is out of scope.
+- Beginner users must not need to choose languages, scanners, or security
+ frameworks before the first useful result.
+- Review waiting is not a delivery blocker; technical failures, permissions,
+ and external service outages are blockers.
+- Customer code and logs must be treated as sensitive. Product analytics must
+ work from metadata and redacted finding summaries.
+
+## Market And Product Signals
+
+AppGuardrail should position itself between classic SAST tools and application
+security posture management:
+
+- GitHub Code Security is listed at 30 USD per active committer per month, and
+ GitHub Secret Protection at 19 USD per active committer per month. AppGuardrail
+ should not compete as another per-seat scanner only; it should compete on
+ zero-configuration guidance, AI-builder workflows, and issue operations.
+ Source: https://github.com/security/plans
+- Snyk's public plan page emphasizes developer-first scanning, SAST, SCA,
+ container, IaC, prioritization, and reporting. AppGuardrail should integrate
+ complementary engines rather than replace all of them. Source:
+ https://snyk.io/plans/
+- Gartner describes ASPM tools as systems that ingest data from multiple SDLC
+ sources, maintain software inventory, correlate findings, and prioritize
+ remediation. AppGuardrail's org failure collector is a first IssueOps step in
+ this direction. Source:
+ https://www.gartner.com/reviews/market/application-security-posture-management-aspm-tools
+- OWASP SAMM organizes security maturity into 15 practices across 5 business
+ functions. AppGuardrail should map product evidence to governance, design,
+ implementation, verification, and operations. Source:
+ https://owaspsamm.org/about/
+- OWASP Top 10, MITRE CWE, and CISA KEV provide the external language for
+ severity, education, and prioritization. AppGuardrail should map findings to
+ these references without turning product advisories into noisy regex rules.
+ Sources: https://owasp.org/www-project-top-ten/,
+ https://cwe.mitre.org/, https://www.cisa.gov/known-exploited-vulnerabilities-catalog
+- Semgrep's public rules and taint-mode model show why AppGuardrail should use
+ external engines for deep interprocedural detection, while keeping
+ beginner-safe built-in checks for fast first value. Sources:
+ https://github.com/semgrep/semgrep-rules,
+ https://docs.semgrep.dev/writing-rules/data-flow/taint-mode/overview
+
+## Product Thesis
+
+AppGuardrail should become the security operating layer for AI-built apps:
+
+1. The CLI gives a beginner a useful result in less than 5 minutes.
+2. The core library normalizes rules, language detection, external engine
+ results, and remediation metadata.
+3. The IssueOps layer turns CI failures, Strix/OpenCode/AppGuardrail findings,
+ and security logs into deduplicated issues that teams can act on.
+4. The report layer turns technical findings into founder, agency, and buyer
+ diligence deliverables.
+5. The future hosted control plane gives teams a dashboard for repository
+ posture, triage, and remediation progress without requiring them to become
+ AppSec specialists.
+
+## Architecture Decision
+
+Separate library work is appropriate, but a Git submodule is not the right first
+move.
+
+Decision:
+
+- Keep one repository for now.
+- Introduce an in-repo `appguardrail_core` package when implementation begins.
+- Keep the existing `scanner.cli.appguardrail` CLI as the compatibility entry
+ point.
+- Move reusable domain logic into `appguardrail_core` only when it has at least
+ two consumers: CLI, tests, future web/API worker, report generator, or org
+ collector.
+- Avoid a Git submodule until there is a hard boundary such as an independently
+ versioned commercial engine, a separate SDK repository, or a licensing split.
+
+Why:
+
+- Submodules increase setup friction for beginner users and CI maintainers.
+- The current package is small and alpha-stage; premature repo splitting would
+ slow scanner and UX iteration.
+- An in-repo core package gives clean API boundaries while preserving one
+ release, one test suite, and one issue tracker.
+
+Target core boundaries:
+
+- `appguardrail_core.language`: file inventory and language/framework profile
+ detection.
+- `appguardrail_core.rules`: built-in rule loading, metadata validation, CWE /
+ OWASP / KEV references, severity policy.
+- `appguardrail_core.findings`: normalized finding schema, deduplication,
+ deploy-blocking policy, SARIF/JSON output.
+- `appguardrail_core.external`: adapters for Bandit, Ruff, Semgrep, Trivy,
+ CodeQL evidence imports, and ZAP baseline runs.
+- `appguardrail_core.issueops`: redaction, CI log compression, issue markers,
+ duplicate suppression.
+- `appguardrail_core.reports`: founder-friendly, agency, fix-pack, and buyer
+ diligence reports.
+
+## Beginner-Safe Language Profiles
+
+Users should run `appguardrail scan .` and get the union of relevant checks.
+They should not need to select a profile.
+
+### Python + Web
+
+Detection inputs:
+
+- `.py`, `pyproject.toml`, `requirements.txt`, `Pipfile`, `poetry.lock`
+- Flask, Django, FastAPI, Starlette, Jinja2, requests, PyYAML, SQLAlchemy
+- HTML/templates and web config files when present
+
+Default coverage:
+
+- Built-in Python/web patterns: unsafe deserialization, `requests(...,
+ verify=False)`, Flask debug, Jinja autoescape off, CSRF exemptions, predictable
+ temp files, hardcoded secrets, CORS wildcards, exposed admin routes.
+- External auto mode: Bandit and Ruff security rules when installed, Semgrep
+ when runnable, Trivy filesystem scan when requested or configured.
+- ZAP baseline only when an authorized URL is supplied by `--zap-baseline` or
+ `APPGUARDRAIL_TARGET_URL`.
+
+### Java Only
+
+Detection inputs:
+
+- `.java`, `pom.xml`, `build.gradle`, `settings.gradle`, `gradle.lockfile`
+- Spring Security, servlet filters, JWT libraries, Jackson serialization,
+ Maven/Gradle dependencies
+
+Default coverage:
+
+- Built-in Java patterns: Spring CSRF disabled, allow-all hostname verifier,
+ native deserialization entry points, JWT `none`, insecure cookie flags,
+ hardcoded secrets, risky CORS/security header disables.
+- External auto mode: Semgrep when runnable, Trivy for dependency/IaC/container
+ evidence when configured, CodeQL SARIF import when a workflow artifact exists.
+
+### Java + Node.js + TypeScript
+
+Detection inputs:
+
+- Java inputs plus `package.json`, `pnpm-lock.yaml`, `yarn.lock`,
+ `package-lock.json`, `.ts`, `.tsx`, `.js`, `.jsx`
+- Express, Next.js, NestJS, React, Vite, JWT, CORS, Helmet, Stripe, Firebase,
+ Supabase
+
+Default coverage:
+
+- Merge Java, JavaScript, TypeScript, and web axes; do not create a special
+ combo preset.
+- Detect monorepo workspaces and scan package boundaries independently.
+- Prioritize cross-service risks: public CORS with credentials, disabled TLS,
+ missing JWT verification, client-exposed secrets, webhook signature bypass,
+ server action/API route auth gaps, insecure cookie/session settings.
+
+### Language-Agnostic CI Failure Logs
+
+Detection inputs:
+
+- GitHub Actions run/job logs from Strix, OpenCode, AppGuardrail, Trivy, CodeQL,
+ and Security Process workflows.
+- Known failure strings such as `VULN-`, `::error::`, `Unable to map Strix
+ findings`, `RateLimitError`, `CRITICAL`, `HIGH`, and timeout markers.
+
+Default coverage:
+
+- Redact tokens, keys, JWTs, and Authorization headers.
+- Store deduplication markers per repository, workflow, run, and job.
+- Create or reopen AppGuardrail issues by `repo + workflow`.
+- Keep compressed logs in issues and preserve raw GitHub Actions links.
+
+## Product Design Plan
+
+The sellable product surface must be useful on first screen. No marketing-only
+landing page should be the primary experience.
+
+Primary screens:
+
+- Repository posture overview: risk by repo, language, workflow, owner, and age.
+- Scan run detail: findings grouped by deploy blocker, language, CWE/OWASP, and
+ remediation owner.
+- IssueOps inbox: Strix/OpenCode/AppGuardrail/Trivy/CodeQL failures grouped by
+ recurring workflow and deduplicated run/job evidence.
+- Beginner onboarding: one command, detected stack, next action, and safe
+ explanation of why a finding matters.
+- Report builder: founder report, agency handoff, buyer diligence export, and
+ fix-pack export.
+- Settings: GitHub App permissions, authorized ZAP targets, redaction policy,
+ external engine availability, and data retention.
+
+Figma scope without Code Connect:
+
+- Create a Figma file or equivalent design spec for the screens above.
+- Use component names that map to product concepts, not implementation classes.
+- Include empty, loading, redacted, partial-permission, no-findings, and
+ high-risk states.
+- Do not use Figma Code Connect in this workstream.
+
+## Data Analytics Plan
+
+North-star metric:
+
+- Weekly repositories with at least one AppGuardrail-confirmed risk reduced or
+ prevented.
+
+Activation metrics:
+
+- Time from install to first useful finding: target under 5 minutes.
+- Percent of first scans requiring no language/profile flags: target above 95%.
+- Percent of scans with actionable next step in output: target above 95%.
+
+Detection quality metrics:
+
+- Built-in fixture precision for deploy-blocking findings: target above 90%.
+- Duplicate CI failure issue suppression: target above 99% on replay tests.
+- Redaction regression tests for tokens/JWTs/Authorization: target 100% pass.
+- External-engine fallback clarity: missing optional tools should explain what
+ was skipped without failing auto mode.
+
+Commercial readiness metrics:
+
+- 3 pilot organizations or internal org equivalents scanned weekly.
+- 20 active repositories under monitoring.
+- 50 recurring security failures automatically grouped into actionable issues.
+- 10 founder-friendly reports generated from real scans.
+- 5 buyer-diligence exports generated without manual editing.
+
+First implementation slice:
+
+- Added `appguardrail_core.metrics` as the reusable KPI scoring boundary.
+- Encodes activation, detection quality, and commercial readiness targets from
+ this plan in `score_sale_readiness(inputs)`.
+- Returns `sale-ready`, `pilot-ready`, or `not-ready` plus unmet KPI detail so
+ the future dashboard, reports, and release checks can share one contract.
+- Treats time-to-first-finding, zero-config scans, fixture precision, redaction,
+ and buyer diligence exports as critical gaps that block sale-readiness even
+ when most softer metrics pass.
+
+## Packaging And Pricing Hypotheses
+
+Open-source base:
+
+- Free CLI, built-in rules, report templates, and GitHub Actions monitor.
+- Purpose: trust, distribution, and reproducible security education.
+
+Team package:
+
+- Hosted dashboard, org-wide IssueOps, report exports, scheduled scans, and
+ external engine orchestration.
+- Buyer: agencies, AI app studios, small SaaS teams.
+
+Enterprise package:
+
+- GitHub App install, audit trail, retention controls, SSO-ready architecture,
+ advanced triage, policy exceptions, and buyer diligence evidence.
+- Buyer: organizations adopting AI-assisted development at scale.
+
+The pricing proof should compare against per-active-committer security products,
+but the sales message should be outcome-based: fewer launch blockers, faster
+security triage, and buyer-ready evidence for AI-built apps.
+
+## Execution Workstreams
+
+### WS0: Product Readiness Baseline
+
+Deliverables:
+
+- This sale-readiness plan in `docs/product/`.
+- README link from the public documentation surface.
+- Public roadmap issue or PR body with milestones and acceptance criteria.
+
+Acceptance:
+
+- The repo states what AppGuardrail is becoming, why it is commercially
+ distinct, and what is required before 2B KRW sale readiness can be argued.
+
+### WS1: Core Library Split
+
+Deliverables:
+
+- Add `appguardrail_core` package inside this repo.
+- Move language detection, finding schema, redaction/compression, and rule
+ metadata validation behind stable functions.
+- Keep `appguardrail` CLI behavior backward compatible.
+
+Acceptance:
+
+- Existing tests pass.
+- New unit tests cover core APIs directly.
+- CLI output remains compatible for current users.
+- No Git submodule is introduced.
+
+First implementation slices:
+
+- Added `appguardrail_core.issueops` for redaction, log compression, issue
+ markers, title/body/comment formatting, and duplicate suppression.
+- Added `appguardrail_core.findings` for normalized finding defaults, severity
+ counts, deploy-blocking policy, report-safe snippets, and stable sorting.
+- Kept scanner compatibility wrappers where tests or current CLI behavior import
+ private helpers directly, while moving shared policy into core.
+
+### WS2: Language Profile Matrix
+
+Deliverables:
+
+- Structured language/framework detection matrix.
+- Python web, Java, JavaScript/TypeScript, and mixed-stack profile tests.
+- External engine availability report in scan output.
+
+First implementation slice:
+
+- Added `appguardrail_core.language` as the reusable zero-config profile
+ boundary.
+- Detects language axes from both source files and common manifests, so a
+ beginner does not have to choose `python`, `java`, or `typescript` before the
+ first scan.
+- Covers the requested first profiles: Python web, Java-only, and Java +
+ Node.js/TypeScript.
+- Prints a beginner-facing profile summary and optional external engine plan
+ from `appguardrail scan .` while preserving existing CLI behavior.
+- Added `appguardrail_core.external` as the external SAST/DAST planning
+ boundary for Bandit, Ruff, Semgrep, Trivy, and ZAP.
+- Auto mode now produces a tested run/skip plan from detected languages and
+ tool availability; missing optional engines are explained without failing the
+ beginner's first scan, while explicitly forced engines still fail loudly.
+
+Acceptance:
+
+- `appguardrail scan .` works without flags for Python web, Java-only, and
+ Java+Node+TypeScript fixtures.
+- Optional engines are skipped cleanly in auto mode and fail loudly when forced.
+
+### WS3: Rule Knowledge Base
+
+Deliverables:
+
+- Rule metadata schema with CWE, OWASP Top 10, SAMM practice, source, and
+ remediation prompt fields.
+- Keep CVE/KEV as prioritization and dependency/SCA references, not raw regex
+ sources.
+- Add validation tests that block rules without required metadata.
+
+First implementation slice:
+
+- Added `appguardrail_core.rules` with a normalized `RuleMetadata` envelope.
+- Extracts public OWASP/CWE/CVE references already present in rule copy and
+ adds conservative category defaults when rule copy does not yet include an
+ explicit taxonomy reference.
+- Attaches `references`, `owasp`, `cwe`, `samm_practice`, and `remediation` to
+ every normalized finding emitted by scanner providers.
+- Added validation tests so report-generation code can reject findings that
+ lack public taxonomy or remediation metadata.
+
+Acceptance:
+
+- Every built-in rule has traceable metadata.
+- Reports can group findings by risk category and buyer-friendly explanation.
+
+### WS4: IssueOps And Org Collector Productization
+
+Deliverables:
+
+- Extract collector redaction, compression, and marker parsing into core.
+- Add replay fixtures for Strix/OpenCode/AppGuardrail/Trivy/CodeQL logs.
+- Add issue comment templates oriented around beginner triage.
+
+First implementation slice:
+
+- Extracted reusable IssueOps helpers into `appguardrail_core.issueops`.
+- Kept the GitHub collector as orchestration code only.
+- Preserved duplicate suppression, redaction, compressed comments, and Strix
+ run URL handling through focused tests.
+
+Acceptance:
+
+- The known Strix failure URL pattern is represented by tests.
+- Replay does not create duplicate issue comments.
+- Redaction tests cover token, JWT, Authorization, and API key patterns.
+
+### WS5: Control Plane UX And Figma
+
+Deliverables:
+
+- Figma file or design spec for posture overview, scan detail, IssueOps inbox,
+ onboarding, report builder, and settings.
+- No Figma Code Connect.
+- Product copy for beginner-safe explanations and enterprise trust states.
+
+Acceptance:
+
+- Design states cover empty, loaded, high-risk, redacted, partial-permission,
+ no-findings, and external-tool-missing conditions.
+- The design can be implemented without inventing new product concepts.
+
+### WS6: Reports And Buyer Diligence
+
+Deliverables:
+
+- Buyer diligence report template.
+- Founder/agency/fix-pack exports backed by normalized findings.
+- Release, license, dependency, and privacy notes.
+
+First implementation slice:
+
+- Added `appguardrail_core.reports` with
+ `render_buyer_diligence_report(findings, context)`.
+- Generates an executive readout, scope/evidence handling section, findings
+ summary, detailed findings, and buyer follow-up checklist from normalized
+ findings.
+- Uses deploy-blocking context, public taxonomy metadata, remediation, and
+ verification fields without including raw secrets or full logs.
+- Added `reports/templates/buyer-diligence.md` to document the generated report
+ structure expected by the Product Design report-builder surface.
+- Added `appguardrail report buyer-diligence --findings findings.json` so a
+ pilot user or diligence reviewer can generate the markdown export from a
+ findings JSON file without writing Python code.
+- Added `appguardrail scan --findings-json reports/findings.json` so scan
+ evidence can feed diligence reports and future dashboards without manual JSON
+ assembly.
+- Added `appguardrail report founder-friendly`, `agency`, and `fix-pack`
+ exports backed by the same normalized findings JSON contract:
+ - founder-friendly: plain-language launch readiness and copy/paste fix
+ prompts for non-security founders.
+ - agency: client-ready methodology, severity sections, priority matrix, and
+ retest notes.
+ - fix-pack: AI-ready remediation work items and verification tests.
+
+Acceptance:
+
+- A pilot buyer can see what was scanned, what was found, what was fixed, what
+ remains accepted risk, and how evidence maps to OWASP/CWE/SAMM.
+
+### WS6.5: Product Metrics And Diligence Scorecard
+
+Deliverables:
+
+- Add a core KPI model for activation, quality, and commercial readiness.
+- Expose a sale-readiness score that can feed reports, dashboards, and release
+ discipline without copying thresholds into each surface.
+- Keep analytics privacy-preserving by accepting aggregate counts and rates
+ instead of raw code, raw logs, or user-identifying event streams.
+
+First implementation slice:
+
+- Added `SaleReadinessInputs`, `MetricResult`, `SaleReadinessScore`, and
+ `score_sale_readiness` in `appguardrail_core.metrics`.
+- Added tests for all-pass sale readiness, pilot-ready noncritical gaps,
+ critical buyer/readiness gaps, and strict threshold behavior.
+
+Acceptance:
+
+- Product readiness can be measured with a deterministic, tested API.
+- KPI gaps are explicit enough for a founder, pilot buyer, or diligence reviewer
+ to see what blocks the 2B KRW sale-readiness argument.
+
+### WS7: Merge And Release Discipline
+
+Deliverables:
+
+- PRs remain small enough to review quickly.
+- Each PR includes tests or explicit no-code validation.
+- Merge when checks pass and no actual technical blocker remains.
+
+Acceptance:
+
+- Review waiting alone does not stop delivery.
+- Branch protection, CI failures, permissions, or external service failures are
+ treated as real blockers and documented.
+
+## Immediate Next PR Slices
+
+1. Merge this plan and README link.
+2. Add `appguardrail_core.issueops` by extracting redaction, log compression,
+ marker parsing, and duplicate key behavior from the org collector.
+3. Add `appguardrail_core.language` with fixture-backed detection for Python
+ web, Java-only, and Java+Node+TypeScript.
+4. Add a rule metadata schema and migrate existing built-in rule definitions to
+ include CWE/OWASP/SAMM/source/remediation fields.
+5. Add a buyer diligence report template fed by normalized findings.
+6. Create the no-Code-Connect Figma design artifact or, if Figma tools remain
+ unavailable, maintain the design spec in repo until the file can be created.
+
+## Definition Of 2B KRW Sale Readiness
+
+AppGuardrail reaches the target standard when all of the following are true:
+
+- A buyer can understand the product in one command, one dashboard, and one
+ diligence report.
+- The scanner is beginner-safe across Python web, Java, and Java+Node/TypeScript
+ without profile selection.
+- Built-in findings are traceable to public security references.
+- External engines add depth without becoming mandatory setup.
+- Org security workflow failures are collected, redacted, deduplicated, and
+ turned into actionable issues.
+- Product metrics show first value, quality, adoption, and remediation outcomes.
+- The repo has a clear core boundary that supports future hosted or commercial
+ packaging without submodule friction.
+- There is enough design, documentation, tests, and release discipline for a
+ diligence reviewer to see repeatable execution rather than a one-off script.
diff --git a/docs/product/2026-07-03-phase3-org-readiness-plan.md b/docs/product/2026-07-03-phase3-org-readiness-plan.md
new file mode 100644
index 0000000..66c3dce
--- /dev/null
+++ b/docs/product/2026-07-03-phase3-org-readiness-plan.md
@@ -0,0 +1,97 @@
+# AppGuardrail Phase 3 Org Readiness Plan
+
+Date: 2026-07-03
+Status: Active execution plan
+Goal: make AppGuardrail credible as a 2B KRW sale-readiness product by turning
+ContextualWisdomLab organization evidence into repeatable product inputs.
+
+## Live Evidence Reviewed
+
+- `ContextualWisdomLab/appguardrail` default branch is `develop`.
+- `ContextualWisdomLab` had 26 non-archived repositories at the review point:
+ 20 non-forks and 6 forks.
+- Primary-language distribution at the review point: Python 11, TypeScript 4,
+ JavaScript 3, Shell 2, R 2, Rust 1, Java 1, C++ 1, Kotlin 1.
+- GitHub search returned 200 open PRs before exhausting the requested page,
+ which means the org has at least 200 open PRs needing classification.
+- A later repo-by-repo detailed PR pass with a 30 PR per repository cap
+ classified 309 open PRs: 109 source conflicts, 61 source review items,
+ 54 needs-triage items, 34 CI failures, 24 review-required items,
+ 21 external-queued gates, and 6 merge-ready PRs.
+- `appguardrail` itself had 6 open PRs. PR #157 was conflicting and had
+ current unresolved product-compatibility review comments, so it was not a
+ review-process-only blocker.
+- GitHub Actions required checks for recent `develop` and PR runs were queued,
+ which is operational evidence but not a source-code blocker under the current
+ execution policy.
+- Product Design saved context was not configured, so current repo files,
+ GitHub state, and generated Figma artifacts are the source of truth.
+- Ponytail debt scan returned no `ponytail:` markers.
+
+## Plugin Perspectives Applied
+
+### Superpowers
+
+Use a small branch per sale-readiness increment. Keep each increment backed by
+a plan, tests, verification, PR, and explicit blocker classification.
+
+### Product Design
+
+The beginner-facing product cannot require a user to choose language profiles,
+scanner engines, or workflow categories before first value. The useful first
+screen is an operational posture surface: repo coverage, queued gates, source
+work, CI failures, and report exports.
+
+### Figma
+
+Figma Code Connect remains out of scope. Use FigJam/Figma artifacts only to
+explain the product loop and buyer demo flow: repos and PRs become normalized
+org intelligence, org intelligence drives IssueOps and reports, and reports
+support founder and buyer diligence conversations.
+
+### Data Analytics
+
+The KPI model should consume aggregate organization facts, not raw customer
+code or full logs. The first measurable org facts are active repository count,
+supported language coverage, PR gate split, CI failure routing, report exports,
+and duplicate suppression.
+
+### Ponytail
+
+No local `ponytail:` debt markers were found in this worktree. Future shortcuts
+must name a ceiling and upgrade trigger before merge.
+
+## Library Split Decision
+
+Do not introduce a submodule in Phase 3.
+
+The repo already has `appguardrail_core`, and the next useful boundary is an
+in-repo `org_intelligence` module. It can later become a separately versioned
+package only after it has at least three stable consumers, such as CLI report
+generation, scheduled GitHub workflow, hosted dashboard, and buyer diligence
+export.
+
+## Immediate Implementation Slice
+
+1. Add `appguardrail_core.org_intelligence`.
+2. Normalize GitHub repo JSON into an organization inventory.
+3. Normalize open PR JSON into a gate summary that separates source conflicts,
+ source review work, CI failures, queued checks, and review waiting.
+4. Render a markdown org readiness report from the normalized model.
+5. Add `scripts/ci/render_org_readiness_report.py` so live GitHub repository
+ and PR state can be converted into a report artifact without sending raw
+ code or logs outside GitHub.
+6. Keep checks queued and review-process waiting as external gates, while
+ keeping conflicts and actual change-requested PRs as product work.
+
+## Acceptance Criteria
+
+- Unit tests cover repository counting, language coverage, PR gate
+ classification, and report recommendations.
+- The report identifies unsupported language families that should start with
+ external engines before built-in regex promotion.
+- The implementation keeps `appguardrail_core` importable with no dependencies.
+- `python3 -m py_compile`, focused pytest, full pytest, and `appguardrail scan .`
+ pass on the branch.
+- The Figma board is updated with the Phase 3 org intelligence flow and does
+ not use Code Connect.
diff --git a/docs/product/2026-07-03-phase4-actionable-org-readiness-plan.md b/docs/product/2026-07-03-phase4-actionable-org-readiness-plan.md
new file mode 100644
index 0000000..a8ae93b
--- /dev/null
+++ b/docs/product/2026-07-03-phase4-actionable-org-readiness-plan.md
@@ -0,0 +1,73 @@
+# AppGuardrail Phase 4 Actionable Org Readiness Plan
+
+Date: 2026-07-03
+Status: Active execution plan
+Goal: make the organization readiness report useful as a beginner-facing and
+buyer-facing action surface, not only a count summary.
+
+## Live Evidence Reviewed
+
+- `ContextualWisdomLab/appguardrail` default branch is `develop`.
+- Latest merged AppGuardrail commit reviewed: `4c8bea5`.
+- `ContextualWisdomLab` currently has 26 non-archived repositories:
+ 20 non-forks and 6 forks.
+- Primary-language distribution remains Python 11, TypeScript 4, JavaScript 3,
+ Shell 2, R 2, Rust 1, Java 1, C++ 1, Kotlin 1.
+- AppGuardrail currently has 6 open PRs, and all 6 are `CONFLICTING` /
+ `DIRTY`. Those are product/source-work gates, not review-process-only gates.
+- No `ponytail:` markers were found in the Phase 4 worktree.
+
+## Plugin Perspectives Applied
+
+### Superpowers
+
+Keep this as a small, mergeable branch. The work should have a written plan,
+focused tests, full validation, PR, merge, and ruleset restoration evidence.
+
+### Product Design
+
+The report should tell a beginner what to do first. A table of gate counts is
+not enough. The useful surface is: action bucket, top repo by source work, and
+first action wording that separates source work, CI failures, and external wait.
+
+### Figma
+
+No Code Connect. Update the FigJam product loop to show that PR gate data now
+becomes action buckets and repo priorities before it becomes buyer evidence.
+
+### Data Analytics
+
+The key measurement improvement is to convert raw PR states into stable action
+buckets: `source-work`, `ci-failure`, `external-wait`, `merge-ready`, and
+`needs-triage`. These buckets are more useful than raw GitHub states because
+they determine what a team should do next.
+
+### Ponytail
+
+No deliberate shortcut marker exists. Phase 4 should not add one; the intended
+increment is small enough to finish without a deferral.
+
+## Library Split Decision
+
+Do not introduce a submodule or separate repository in Phase 4.
+
+`appguardrail_core.org_intelligence` already has the right in-repo boundary.
+The next step is to make that core model richer while keeping the CLI/report
+script stable. A separate package should wait until there is a hosted service
+or SDK consumer outside this repository.
+
+## Immediate Implementation Slice
+
+1. Add action buckets for PR gates.
+2. Add top repository summaries by actionable work.
+3. Add first-action recommendations to the markdown org readiness report.
+4. Keep existing `summarize_pr_gates()` and report script behavior compatible.
+5. Add focused tests for bucket mapping, top repo priority, and report copy.
+
+## Acceptance Criteria
+
+- Focused org-intelligence tests pass.
+- Full pytest passes.
+- `appguardrail scan .` has zero critical/high deploy blockers.
+- A live org readiness report can still be rendered from GitHub repo JSON.
+- Figma board is updated without Code Connect.
diff --git a/docs/product/2026-07-03-phase5-buyer-evidence-pack-plan.md b/docs/product/2026-07-03-phase5-buyer-evidence-pack-plan.md
new file mode 100644
index 0000000..7346a51
--- /dev/null
+++ b/docs/product/2026-07-03-phase5-buyer-evidence-pack-plan.md
@@ -0,0 +1,86 @@
+# AppGuardrail Phase 5 Buyer Evidence Pack Plan
+
+Date: 2026-07-03
+Status: Active execution plan
+Goal: make organization readiness output useful as buyer due-diligence evidence
+that a beginner can generate without choosing languages, tools, or workflow
+categories.
+
+## Live Evidence Reviewed
+
+- `ContextualWisdomLab/appguardrail` default branch is `develop`.
+- Latest live `origin/develop` reviewed: `9aecc290`.
+- `ContextualWisdomLab` has 26 non-archived repositories: 20 non-forks, 6
+ forks, and 3 private repositories.
+- Primary-language distribution is Python 11, TypeScript 4, JavaScript 3, R 2,
+ Shell 2, C++ 1, Java 1, Kotlin 1, Rust 1.
+- AppGuardrail has 6 open PRs; all remain source-work gates rather than
+ review-process-only blockers.
+- A live repo-by-repo PR pass with a 30 PR cap analyzed 308 open PRs:
+ 171 source-work, 51 needs-triage, 45 external-wait, 35 CI-failure, and
+ 6 merge-ready items.
+- Top source-work repositories are `ContextualWisdomLab/codec-carver`,
+ `ContextualWisdomLab/.github`, `ContextualWisdomLab/pg-erd-cloud`,
+ `ContextualWisdomLab/fast-mlsirm`, and
+ `ContextualWisdomLab/ContextualWisdomLab.github.io`.
+- CodeGraph is not initialized in this checkout, so Phase 5 uses direct source
+ reads and focused tests instead of CodeGraph queries.
+
+## Plugin Perspectives Applied
+
+### Superpowers
+
+Keep this as one mergeable branch with a written plan, focused tests, full
+verification, PR, merge, and ruleset restoration evidence.
+
+### Product Design
+
+The output should work as a first-run beginner surface: status, observed value,
+target, and next action. A buyer should not need to interpret raw GitHub states
+or know which scanner to run first.
+
+### Figma
+
+No Code Connect. Update the existing FigJam board with the Phase 5 flow:
+org facts become PR gates, action buckets, KPI checks, a 7-day plan, and a
+buyer evidence packet.
+
+### Data Analytics
+
+Treat the report as a decision artifact. The relevant KPIs are active repo
+coverage, supported language coverage, source-work burden, CI-failure burden,
+and reusable evidence export availability. Each must show pass, warn, or fail.
+
+### Ponytail
+
+Do not split a package or add a submodule yet. The lazy boundary is the existing
+`appguardrail_core.org_intelligence` module plus one JSON export flag. Split
+only after there are multiple external consumers.
+
+## Library Split Decision
+
+Do not create a separate library or submodule in Phase 5.
+
+The reusable boundary already exists inside `appguardrail_core`. A separate
+package would add release and compatibility work before there is a hosted
+service, SDK, or third-party integration consuming it independently.
+
+## Immediate Implementation Slice
+
+1. Add a buyer evidence pack model to `appguardrail_core.org_intelligence`.
+2. Compute pass/warn/fail KPI rows from existing inventory and PR summaries.
+3. Add a 7-day execution plan generated from the same facts.
+4. Append the evidence pack to the Markdown organization readiness report.
+5. Add `--json-out` to the report script so dashboards or buyer packets can
+ reuse the same evidence without parsing Markdown.
+
+## Acceptance Criteria
+
+- Focused tests cover KPI status, JSON shape, Markdown output, and 7-day plan.
+- Full pytest passes.
+- `python3 -m py_compile` passes for changed Python modules.
+- `git diff --check` passes.
+- `python3 scanner/cli/appguardrail.py scan .` reports zero deploy-blocking
+ critical/high issues.
+- A live org report can render both Markdown and JSON from GitHub state.
+- The Figma board is updated without Code Connect.
diff --git a/docs/product/2026-07-03-phase6-buyer-evidence-bundle-plan.md b/docs/product/2026-07-03-phase6-buyer-evidence-bundle-plan.md
new file mode 100644
index 0000000..49727bd
--- /dev/null
+++ b/docs/product/2026-07-03-phase6-buyer-evidence-bundle-plan.md
@@ -0,0 +1,81 @@
+# AppGuardrail Phase 6 Buyer Evidence Bundle Plan
+
+Date: 2026-07-03
+Status: Active execution plan
+Goal: package the organization readiness report into a one-command buyer
+evidence bundle that a beginner can generate without choosing output files,
+scanner families, or GitHub gate terminology.
+
+## Live Evidence Reviewed
+
+- `ContextualWisdomLab/appguardrail` default branch is `develop`.
+- Latest live `origin/develop` reviewed: `c6399f43`.
+- `ContextualWisdomLab` has 26 non-archived repositories: 20 non-forks, 6
+ forks, and 3 private repositories.
+- Primary-language distribution is Python 11, TypeScript 4, JavaScript 3, R 2,
+ Shell 2, C++ 1, Java 1, Kotlin 1, and Rust 1.
+- AppGuardrail has 6 open PRs; all remain source-work gates rather than
+ review-process-only blockers.
+- The org-level central required workflow ruleset is active.
+- CodeGraph is not initialized in this checkout, so this phase uses direct
+ source reads and focused tests instead of CodeGraph queries.
+
+## Plugin Perspectives Applied
+
+### Superpowers
+
+Keep this as one isolated branch with a written plan, focused test coverage,
+full verification, PR, merge, and ruleset restoration evidence. Review waiting
+and queued checks are tracked but are not blockers.
+
+### Product Design
+
+The beginner-facing surface is the bundle directory, not a matrix of flags.
+Each file has a clear job: narrative, machine-readable evidence, manifest, and
+operator README. The manifest makes the evidence auditable without asking the
+user to understand raw GitHub API fields.
+
+### Figma
+
+No Code Connect. Update the existing FigJam board with the Phase 6 evidence
+bundle flow: live GitHub state, readiness summarization, bundle artifacts, and
+buyer data-room use.
+
+### Data Analytics
+
+Treat the bundle as a decision artifact. The manifest must include the source,
+generated time, repo and PR counts, action buckets, and overall KPI status so a
+buyer or dashboard can validate the snapshot without parsing Markdown.
+
+### Ponytail
+
+Do not split a separate library or add a submodule in this phase. The existing
+`appguardrail_core.org_intelligence` contract and one CLI script are enough.
+Split only after a hosted service, SDK, or independent third-party consumer
+needs versioned access.
+
+## Bundle Contract
+
+`scripts/ci/render_org_readiness_report.py --bundle-dir
` writes:
+
+- `org-readiness.md`: human-readable organization readiness report.
+- `buyer-evidence.json`: machine-readable KPI payload.
+- `manifest.json`: generated timestamp, data sources, repo-level collection
+ warnings, artifact names, repo and PR counts, action buckets, and buyer
+ evidence status.
+- `README.md`: beginner-readable instructions for using the bundle.
+
+The existing `--out` and `--json-out` flags remain supported for automation
+that already knows exactly which single file it wants.
+
+## Acceptance Criteria
+
+- Focused tests cover bundle file creation and manifest summary fields.
+- Full pytest passes.
+- `python3 -m py_compile` passes for changed Python modules.
+- `git diff --check` passes.
+- A live org bundle can render from current GitHub state, preserving repo-level
+ GitHub API failures as manifest warnings instead of dropping them silently.
+- `python3 scanner/cli/appguardrail.py scan .` reports zero deploy-blocking
+ critical/high issues.
+- The Figma board is updated without Code Connect.
diff --git a/docs/product/2026-07-03-phase7-cli-org-bundle-plan.md b/docs/product/2026-07-03-phase7-cli-org-bundle-plan.md
new file mode 100644
index 0000000..06b46b1
--- /dev/null
+++ b/docs/product/2026-07-03-phase7-cli-org-bundle-plan.md
@@ -0,0 +1,77 @@
+# AppGuardrail Phase 7 CLI Organization Bundle Plan
+
+Date: 2026-07-03
+Status: Active execution plan
+Goal: make the buyer evidence bundle a first-class AppGuardrail CLI surface
+instead of an internal CI script that beginners need to discover.
+
+## Live Evidence Reviewed
+
+- `ContextualWisdomLab/appguardrail` default branch is `develop`.
+- Latest live `origin/develop` reviewed: `9bc82c4`.
+- `ContextualWisdomLab` has 26 non-archived repositories: 20 non-forks, 6
+ forks, and 3 private repositories.
+- Primary-language distribution is Python 11, TypeScript 4, JavaScript 3, R 2,
+ Shell 2, C++ 1, Java 1, Kotlin 1, and Rust 1.
+- AppGuardrail has 6 open PRs; all are existing source-work or review-work
+ gates unrelated to this phase.
+- The org-level central required workflow ruleset is active, and repo rulesets
+ `17073578` and `17214782` are active.
+- CodeGraph is not initialized in this checkout, so this phase uses direct
+ source reads and focused tests instead of CodeGraph queries.
+
+## Plugin Perspectives Applied
+
+### Superpowers
+
+Use an isolated worktree from live `origin/develop`, keep a written plan,
+verify with focused and full tests, then create, merge, and post-merge verify a
+PR. Review waiting and queued checks are not blockers.
+
+### Product Design
+
+The command should read like a product action: `appguardrail org-bundle`.
+Beginners should not have to know the internal script path or choose output
+file names. The default bundle directory is stable and inspectable.
+
+### Figma
+
+No Code Connect. Update the existing FigJam board with the Phase 7 flow:
+CLI command, live or JSON sources, shared core helper, and buyer artifacts.
+
+### Data Analytics
+
+The CLI output should expose the decision numbers that matter immediately:
+open PRs analyzed, buyer evidence status, and collection warning count. The
+manifest remains the auditable source for dashboards.
+
+### Ponytail
+
+Do not create a separate package, submodule, or service. The smallest correct
+boundary is a shared `appguardrail_core.org_bundle` helper because the bundle
+logic now has two real consumers: the product CLI and the CI report script.
+
+## CLI Contract
+
+`appguardrail org-bundle` writes `appguardrail-buyer-evidence/` by default:
+
+- `org-readiness.md`: human-readable organization readiness report.
+- `buyer-evidence.json`: machine-readable KPI payload.
+- `manifest.json`: source, warning, artifact, repo, PR, action bucket, and KPI
+ metadata.
+- `README.md`: beginner-readable instructions for the bundle.
+
+Automation can still pass `--bundle-dir`, `--owner`, `--repos-json`,
+`--prs-json`, `--prs-repository`, `--per-repo-pr-limit`,
+`--active-repository-target`, and `--generated-at`.
+
+## Acceptance Criteria
+
+- Focused tests cover the CLI command and the script compatibility path.
+- Full pytest passes.
+- `python3 -m py_compile` passes for changed Python modules.
+- `git diff --check` passes.
+- A live CLI bundle can render from current GitHub state.
+- `python3 scanner/cli/appguardrail.py scan .` reports zero deploy-blocking
+ critical/high issues.
+- The Figma board is updated without Code Connect.
diff --git a/docs/release-automation.md b/docs/release-automation.md
index 00d1162..87b0805 100644
--- a/docs/release-automation.md
+++ b/docs/release-automation.md
@@ -25,10 +25,40 @@ The GitHub Actions Bot:
- validates that the requested version is not already on PyPI;
- updates `scanner/cli/appguardrail.py`;
- adds a changelog entry;
+- installs release build tooling from `requirements-release.txt` with
+ `pip --require-hashes`;
+- audits the installed release build tooling environment with `pip-audit`
+ against OSV data;
- builds the source and wheel distributions;
- checks the distributions with `twine`;
+- uploads `release-sbom.cdx.json` and `release-provenance.json` as the
+ `release-supply-chain-evidence` artifact;
- opens or updates a release PR;
- dispatches the release Security Process workflow.
Central required OpenCode and Strix workflows remain the review gates. The bot
prepares the PR; it does not merge or publish on its own.
+
+## Release Dependency Lock
+
+`requirements-release.in` lists the direct release build tools. Regenerate
+`requirements-release.txt` with hashes after changing it:
+
+```bash
+uv pip compile --generate-hashes --python-version 3.13 --universal requirements-release.in -o requirements-release.txt
+```
+
+The prepare and publish workflows install release build tooling only with
+`pip install --require-hashes`. This keeps build, upload, SBOM, and audit tools
+bound to the hashes reviewed in the repository.
+
+## Release Evidence
+
+Both release workflows create a CycloneDX environment SBOM and a provenance JSON
+file that records the workflow identity, commit, Python runtime, hashed release
+requirements file, and SHA-256 digests for the built distributions.
+
+The publish workflow uses PyPI Trusted Publishing through
+`pypa/gh-action-pypi-publish`, so the package upload job keeps `id-token: write`
+isolated to the publishing step. That action publishes digital attestations by
+default for Trusted Publishing flows.
diff --git a/docs/superpowers/plans/2026-07-02-appguardrail-issueops-core.md b/docs/superpowers/plans/2026-07-02-appguardrail-issueops-core.md
new file mode 100644
index 0000000..9b7378e
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-02-appguardrail-issueops-core.md
@@ -0,0 +1,400 @@
+# AppGuardrail IssueOps Core Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Extract reusable IssueOps behavior from the org security failure collector into an in-repo `appguardrail_core` package so the CLI, collector, future dashboard, and reports can share redaction, log compression, issue markers, and duplicate suppression.
+
+**Architecture:** Keep this repository as a monorepo and introduce `appguardrail_core` as an internal package. The existing collector remains the GitHub API orchestration layer; pure text/marker/finding helpers move to `appguardrail_core.issueops`.
+
+**Tech Stack:** Python 3.9+, stdlib only, pytest, setuptools package discovery.
+
+---
+
+## File Structure
+
+- Create: `appguardrail_core/__init__.py`
+ - Package marker and public version-independent description.
+- Create: `appguardrail_core/issueops.py`
+ - Pure IssueOps helpers: security workflow matching, failure conclusion
+ matching, run URL parsing, label sanitization, redaction, log compression,
+ marker parsing/replacement, summary/body/comment formatting.
+- Modify: `scripts/ci/collect_org_security_failures.py`
+ - Keep GitHub API client, collection loop, issue publishing, and CLI args.
+ - Import IssueOps helpers from `appguardrail_core.issueops`.
+- Modify: `pyproject.toml`
+ - Include `appguardrail_core*` in setuptools package discovery.
+- Create: `tests/test_issueops_core.py`
+ - Unit tests for pure core helpers.
+- Modify: `tests/test_org_security_failure_collector.py`
+ - Keep collector integration/publish tests, importing pure helper behavior
+ through the collector module where compatibility matters.
+- Modify: `docs/product/2026-07-02-2b-krw-sale-readiness-plan.md`
+ - Mark WS4 first slice as started/implemented after code lands.
+
+## Task 1: Add Core Package And Pure IssueOps Tests
+
+**Files:**
+- Create: `appguardrail_core/__init__.py`
+- Create: `appguardrail_core/issueops.py`
+- Create: `tests/test_issueops_core.py`
+- Modify: `pyproject.toml`
+
+- [ ] **Step 1: Write the failing core tests**
+
+Create `tests/test_issueops_core.py` with:
+
+```python
+from appguardrail_core import issueops
+
+
+def finding(**overrides):
+ base = {
+ "repo": "ContextualWisdomLab/naruon",
+ "workflow": "Strix Security Scan",
+ "run_id": 28492006630,
+ "run_url": "https://github.com/ContextualWisdomLab/naruon/actions/runs/28492006630",
+ "job_id": 84450511793,
+ "job_name": "strix",
+ "job_url": "https://github.com/ContextualWisdomLab/naruon/actions/runs/28492006630/job/84450511793",
+ "conclusion": "failure",
+ "branch": "develop",
+ "head_sha": "abc123",
+ "event": "pull_request",
+ "pr_numbers": [265],
+ "snippet": "VULN-0001 CRITICAL example",
+ }
+ base.update(overrides)
+ return base
+
+
+def test_security_scope_conclusions_and_run_url_pattern():
+ for name in ("Strix", "OpenCode Review", "AppGuardRail", "Trivy FS", "CodeQL", "Security Process"):
+ assert issueops.is_security_name(name)
+ assert issueops.is_security_name("Java CI", "typescript CodeQL analyze")
+ assert not issueops.is_security_name("pytest", "build")
+ assert all(issueops.is_failure(value) for value in ("failure", "cancelled", "timed_out", "action_required"))
+ assert not any(issueops.is_failure(value) for value in ("success", "skipped", None))
+ repo, run_id = issueops.parse_run_url(
+ "https://github.com/ContextualWisdomLab/naruon/actions/runs/28492006630/job/84450511793#step:21:1"
+ )
+ assert (repo, run_id) == ("ContextualWisdomLab/naruon", 28492006630)
+
+
+def test_redaction_and_log_compression_prioritize_security_context():
+ secret_log = (
+ "\x1b[31m2026-07-01T10:20:30.123Z Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz\n"
+ "token='github_pat_abcdefghijklmnopqrstuvwxyz0123456789'\n"
+ "jwt=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature\n"
+ )
+ redacted = issueops.redact(secret_log)
+ assert "\x1b" not in redacted
+ assert "2026-07-01T10:20:30.123Z" not in redacted
+ assert "ghp_" not in redacted and "github_pat_" not in redacted and "eyJhbGci" not in redacted
+
+ log = "\n".join(
+ [
+ 'echo "::error::source branch should not dominate"',
+ *[f"noise {i}" for i in range(12)],
+ "Unable to map Strix findings",
+ "VULN-0001 CRITICAL browser storage issue",
+ "RateLimitError: retry budget exhausted",
+ *[f"tail noise {i}" for i in range(12)],
+ "::error::actual security failure",
+ ]
+ )
+ snippet = issueops.compress_log(log, max_lines=28, max_chars=5000)
+ assert "VULN-0001 CRITICAL" in snippet
+ assert "RateLimitError" in snippet
+ assert "::error::actual security failure" in snippet
+ assert 'echo "::error::source branch should not dominate"' not in snippet
+ assert "...[compressed]" in snippet
+
+
+def test_marker_body_and_replacement_round_trip():
+ item = finding()
+ body = issueops.issue_body(item, {issueops.seen_key(item)})
+ assert ""
+DEFAULT_MAX_LOG_CHARS = 30_000
+DEFAULT_MAX_LOG_LINES = 200
+
+ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
+TS_RE = re.compile(r"^\ufeff?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s*")
+SECRET_RE = [
+ re.compile(r"(?i)(authorization:\s*(?:bearer|token)\s+)[^\s]+"),
+ re.compile(r"(?i)\b((?:api[_-]?key|token|secret|password|private[_-]?key)\s*[:=]\s*)['\"]?[^'\"\s]+"),
+ re.compile(r"\b(?:gh[opsu]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]+|sk-[A-Za-z0-9]{20,})\b"),
+ re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"),
+]
+PRIMARY_LOG_RE = [
+ re.compile(p, re.IGNORECASE)
+ for p in (
+ r"^\s*::error::",
+ r"traceback",
+ r"vuln-",
+ r"\bcritical\b",
+ r"\bhigh\b",
+ r"ratelimiterror",
+ r"unable to map strix findings",
+ r"\btimeout\b|\btimed out\b",
+ )
+]
+FALLBACK_LOG_RE = [re.compile(r"\bfailed\b|\berror\b|\bfatal\b", re.IGNORECASE)]
+```
+
+Then add the functions with the same behavior as the collector currently has:
+`is_failure`, `is_security_name`, `parse_run_url`, `sanitize_label_value`,
+`redact`, `log_ranges`, `compress_log`, `seen_key`, `marker`, `parse_marker`,
+`replace_marker`, `title`, `summary`, `issue_body`, and `issue_comment`.
+
+- [ ] **Step 5: Update package discovery**
+
+Modify `pyproject.toml`:
+
+```toml
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["scanner*", "appguardrail_core*"]
+namespaces = false
+```
+
+- [ ] **Step 6: Run core tests**
+
+Run:
+
+```bash
+pytest tests/test_issueops_core.py -q
+```
+
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+Run:
+
+```bash
+git add appguardrail_core tests/test_issueops_core.py pyproject.toml
+git commit -m "feat: add issueops core helpers"
+```
+
+Expected: commit succeeds without staging `.Jules/palette.md`.
+
+## Task 2: Update Collector To Use Core Helpers
+
+**Files:**
+- Modify: `scripts/ci/collect_org_security_failures.py`
+- Modify: `tests/test_org_security_failure_collector.py`
+
+- [ ] **Step 1: Replace collector helper definitions with imports**
+
+In `scripts/ci/collect_org_security_failures.py`, keep GitHub API, time
+helpers, collection, publishing, args, and `main`. Import these names:
+
+```python
+from appguardrail_core.issueops import (
+ DEFAULT_MAX_LOG_CHARS,
+ DEFAULT_MAX_LOG_LINES,
+ compress_log,
+ is_failure,
+ is_security_name,
+ issue_body,
+ issue_comment,
+ parse_marker,
+ parse_run_url,
+ replace_marker,
+ sanitize_label_value,
+ seen_key,
+ title,
+)
+```
+
+Remove duplicate regex constants and pure helper functions from the collector.
+
+- [ ] **Step 2: Update collector tests to focus on collector compatibility**
+
+In `tests/test_org_security_failure_collector.py`, keep the existing dynamic
+module import and publish tests. Replace pure helper tests with imports from
+`appguardrail_core.issueops` or leave one compatibility assertion through the
+collector module:
+
+```python
+def test_collector_reexports_core_matching_for_compatibility():
+ assert collector.is_security_name("Strix")
+ assert collector.is_failure("failure")
+ assert collector.parse_run_url(
+ "https://github.com/ContextualWisdomLab/naruon/actions/runs/28492006630/job/84450511793#step:21:1"
+ ) == ("ContextualWisdomLab/naruon", 28492006630)
+```
+
+- [ ] **Step 3: Run focused tests**
+
+Run:
+
+```bash
+pytest tests/test_issueops_core.py tests/test_org_security_failure_collector.py -q
+```
+
+Expected: PASS.
+
+- [ ] **Step 4: Run syntax validation**
+
+Run:
+
+```bash
+python3 -m py_compile scripts/ci/collect_org_security_failures.py appguardrail_core/issueops.py
+```
+
+Expected: command exits 0.
+
+- [ ] **Step 5: Commit**
+
+Run:
+
+```bash
+git add scripts/ci/collect_org_security_failures.py tests/test_org_security_failure_collector.py
+git commit -m "refactor: reuse issueops core in org collector"
+```
+
+Expected: commit succeeds.
+
+## Task 3: Document The First Productization Slice
+
+**Files:**
+- Modify: `docs/product/2026-07-02-2b-krw-sale-readiness-plan.md`
+- Modify: `README.md`
+
+- [ ] **Step 1: Update product plan WS4**
+
+Under `WS4: IssueOps And Org Collector Productization`, add:
+
+```markdown
+First implementation slice:
+
+- Extracted reusable IssueOps helpers into `appguardrail_core.issueops`.
+- Kept the GitHub collector as orchestration code only.
+- Preserved duplicate suppression, redaction, compressed comments, and Strix
+ run URL handling through focused tests.
+```
+
+- [ ] **Step 2: Verify README link remains valid**
+
+Run:
+
+```bash
+python3 - <<'PY'
+from pathlib import Path
+assert Path("docs/product/2026-07-02-2b-krw-sale-readiness-plan.md").exists()
+assert "2B KRW sale readiness plan" in Path("README.md").read_text()
+PY
+```
+
+Expected: command exits 0.
+
+- [ ] **Step 3: Commit**
+
+Run:
+
+```bash
+git add docs/product/2026-07-02-2b-krw-sale-readiness-plan.md README.md docs/superpowers/plans/2026-07-02-appguardrail-issueops-core.md
+git commit -m "docs: record 2b sale readiness plan"
+```
+
+Expected: commit succeeds.
+
+## Final Verification
+
+- [ ] **Step 1: Run all tests**
+
+Run:
+
+```bash
+pytest -q
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 2: Run diff checks**
+
+Run:
+
+```bash
+git diff --check
+git status --short
+```
+
+Expected: `git diff --check` exits 0. `git status --short` may show the
+pre-existing `.Jules/palette.md` modification, but no intended files should be
+unstaged.
+
+- [ ] **Step 3: Push and open PR**
+
+Run:
+
+```bash
+git push -u origin codex/2b-sale-readiness
+gh pr create --base develop --head codex/2b-sale-readiness --title "Productize AppGuardrail IssueOps core" --body-file /tmp/appguardrail-2b-sale-readiness-pr.md
+```
+
+Expected: PR is created against `develop`.
+
+- [ ] **Step 4: Merge when checks permit**
+
+Run:
+
+```bash
+gh pr merge --squash --delete-branch
+```
+
+Expected: PR merges unless branch protection, CI failure, or GitHub permission
+returns a concrete blocker.
diff --git a/docs/superpowers/plans/2026-07-03-org-strix-ssrf-integration.md b/docs/superpowers/plans/2026-07-03-org-strix-ssrf-integration.md
new file mode 100644
index 0000000..9aea68a
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-03-org-strix-ssrf-integration.md
@@ -0,0 +1,242 @@
+# Org Strix And Collector SSRF Integration Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Integrate the still-open AppGuardrail PR #143 and #158 security value into the current `develop` architecture without regressing the sale-readiness core package.
+
+**Architecture:** Keep the reusable scanner and report architecture introduced by `appguardrail_core`; do not merge stale branches that predate that split. Port only the validated Strix/Sentinel detection rules and GitHub job-log redirect hardening into the current files with focused regression tests.
+
+**Tech Stack:** Python 3, pytest, GitHub Actions collector script, AppGuardrail regex scanner, `appguardrail_core` IssueOps helpers.
+
+## Global Constraints
+
+- Do not use Figma Code Connect.
+- Do not stage or modify the unrelated `.Jules/palette.md` worktree change.
+- Treat review process and queued GitHub checks as non-blocking after local verification.
+- Preserve the org ruleset intent; if rulesets must be relaxed for merge, restore exact captured behavior immediately after merge.
+- CodeGraph is optional; this worktree has no `.codegraph/` index, so use direct file inspection for this implementation.
+
+---
+
+### Task 1: Port Strix/Sentinel Scanner Patterns
+
+**Files:**
+- Modify: `scanner/cli/appguardrail.py`
+- Modify: `tests/test_appguardrail.py`
+
+**Interfaces:**
+- Consumes: existing `SCAN_RULES` list and `test_scan_file_detects_strix_derived_patterns`.
+- Produces: rule IDs `python-okta-host-endswith-ssrf`, `python-subprocess-missing-timeout`, `shell-awk-variable-injection`, `node-exec-url-command-injection`, `node-unvalidated-output-path-write`, `python-expanduser-user-path-traversal`, `github-actions-secret-env-passthrough`, `github-actions-secrets-github-token`, `docker-cli-secret-env-leak`, and `html-target-blank-without-noopener`.
+
+- [ ] **Step 1: Add failing regression samples**
+
+Add these cases to the `samples` dictionary in `test_scan_file_detects_strix_derived_patterns`:
+
+```python
+"snowflake.py": {
+ "content": (
+ "parsed = urlparse(authenticator)\n"
+ "if parsed.hostname.endswith('.okta.com'):\n"
+ " return authenticator\n"
+ ),
+ "ids": {"python-okta-host-endswith-ssrf"},
+},
+"slow_process.py": {
+ "content": "subprocess.run(['ffmpeg', '-i', source_path], check=True)\n",
+ "ids": {"python-subprocess-missing-timeout"},
+},
+"extract-frames.sh": {
+ "content": 'awk "BEGIN { print $NUM_FRAMES / $DURATION }"\n',
+ "ids": {"shell-awk-variable-injection"},
+},
+"auth-flow.ts": {
+ "content": "exec(authUrl)\n",
+ "ids": {"node-exec-url-command-injection"},
+},
+"export.ts": {
+ "content": "writeFileSync(output, contents)\n",
+ "ids": {"node-unvalidated-output-path-write"},
+},
+"audio_separator.py": {
+ "content": "audio_file = Path(input_path).expanduser()\n",
+ "ids": {"python-expanduser-user-path-traversal"},
+},
+"strix.yml": {
+ "content": (
+ "env:\n"
+ " LLM_API_KEY: ${{ secrets.LLM_API_KEY }}\n"
+ " REVIEW_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
+ ),
+ "ids": {
+ "github-actions-secret-env-passthrough",
+ "github-actions-secrets-github-token",
+ },
+},
+"backup.sh": {
+ "content": 'docker run -e DB_PASS="$DB_PASS" postgres:16\n',
+ "ids": {"docker-cli-secret-env-leak"},
+},
+"index.html": {
+ "content": 'external\n',
+ "ids": {"html-target-blank-without-noopener"},
+},
+```
+
+- [ ] **Step 2: Run the focused test and verify it fails**
+
+Run:
+
+```bash
+pytest -q tests/test_appguardrail.py::test_scan_file_detects_strix_derived_patterns
+```
+
+Expected before implementation: failure showing at least one missing expected rule ID.
+
+- [ ] **Step 3: Add scanner rules**
+
+Add the ten rule dictionaries to `SCAN_RULES` after `python-absolute-path-traversal-check-missing`. Each rule must include `id`, compiled `pattern`, `severity`, `message` with OWASP/CWE context, and precise `extensions`.
+
+- [ ] **Step 4: Re-run focused scanner tests**
+
+Run:
+
+```bash
+pytest -q tests/test_appguardrail.py::test_scan_file_detects_strix_derived_patterns
+```
+
+Expected after implementation: `1 passed`.
+
+### Task 2: Harden GitHub Job Log Redirect Fetching
+
+**Files:**
+- Modify: `scripts/ci/collect_org_security_failures.py`
+- Modify: `tests/test_org_security_failure_collector.py`
+
+**Interfaces:**
+- Consumes: `GitHub.job_log(repo: str, job_id: int) -> str`.
+- Produces: safe handling for redirect locations before network download, blocking non-HTTP schemes and internal hostnames.
+
+- [ ] **Step 1: Add focused SSRF tests**
+
+Add tests that patch the collector opener/urlopen path to return dangerous redirect locations and assert that `job_log` returns a safe error instead of fetching:
+
+```python
+def test_job_log_rejects_dangerous_redirect_scheme(monkeypatch):
+ client = collector.GitHub("token")
+ monkeypatch.setattr(collector.urllib.request, "build_opener", lambda *_: FakeRedirectOpener("file:///etc/passwd"))
+ assert "Invalid or dangerous URL scheme" in client.job_log("ContextualWisdomLab/naruon", 123)
+
+
+def test_job_log_rejects_internal_redirect_host(monkeypatch):
+ client = collector.GitHub("token")
+ monkeypatch.setattr(collector.urllib.request, "build_opener", lambda *_: FakeRedirectOpener("http://169.254.169.254/latest/meta-data"))
+ assert "Access to internal address blocked" in client.job_log("ContextualWisdomLab/naruon", 123)
+```
+
+- [ ] **Step 2: Run the focused collector tests and verify they fail**
+
+Run:
+
+```bash
+pytest -q tests/test_org_security_failure_collector.py
+```
+
+Expected before implementation: new SSRF tests fail because dangerous redirect locations are not rejected.
+
+- [ ] **Step 3: Add URL safety helpers**
+
+Implement helpers in `scripts/ci/collect_org_security_failures.py`:
+
+```python
+BLOCKED_LOG_HOSTS = {"localhost", "127.0.0.1", "169.254.169.254", "0.0.0.0", "::1"}
+
+
+class SecureRedirectHandler(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ _validate_log_download_url(newurl)
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
+
+
+def _redacted_url(parsed: urllib.parse.ParseResult) -> str:
+ return f"{parsed.scheme}://{parsed.hostname or ''}{parsed.path}"
+
+
+def _validate_log_download_url(url: str) -> urllib.parse.ParseResult:
+ parsed = urllib.parse.urlparse(url)
+ if parsed.scheme not in {"http", "https"}:
+ raise urllib.error.URLError(
+ f"Invalid or dangerous URL scheme in location: {_redacted_url(parsed)}"
+ )
+ if parsed.hostname in BLOCKED_LOG_HOSTS:
+ raise urllib.error.URLError(
+ f"Access to internal address blocked: {_redacted_url(parsed)}"
+ )
+ return parsed
+```
+
+- [ ] **Step 4: Use secure opener in `GitHub.job_log`**
+
+After resolving the redirect `location`, call `_validate_log_download_url(location)`, then download with:
+
+```python
+download_req = urllib.request.Request(location, headers={"User-Agent": UA})
+opener = urllib.request.build_opener(SecureRedirectHandler)
+with opener.open(download_req, timeout=30) as res:
+ return res.read().decode("utf-8", errors="replace")
+```
+
+Catch `urllib.error.URLError` and return `Could not fetch job log: {exc.reason}`.
+
+- [ ] **Step 5: Re-run focused collector tests**
+
+Run:
+
+```bash
+pytest -q tests/test_org_security_failure_collector.py
+```
+
+Expected after implementation: all collector tests pass.
+
+### Task 3: Verify, Publish, And Merge
+
+**Files:**
+- Modify: PR branch metadata only through GitHub CLI/API.
+
+**Interfaces:**
+- Consumes: local branch `codex/org-strix-ssrf-integration`.
+- Produces: merged PR into `develop` with rulesets restored if temporarily relaxed.
+
+- [ ] **Step 1: Run validation**
+
+Run:
+
+```bash
+python3 -m py_compile scanner/cli/appguardrail.py scripts/ci/collect_org_security_failures.py
+pytest -q
+python3 scanner/cli/appguardrail.py scan .
+git diff --check
+```
+
+Expected: all commands exit `0`; AppGuardrail scan reports zero deploy blockers.
+
+- [ ] **Step 2: Commit**
+
+Stage only implementation, test, and plan files:
+
+```bash
+git add scanner/cli/appguardrail.py tests/test_appguardrail.py scripts/ci/collect_org_security_failures.py tests/test_org_security_failure_collector.py docs/superpowers/plans/2026-07-03-org-strix-ssrf-integration.md
+git commit -m "Integrate org Strix patterns and secure log fetches"
+```
+
+- [ ] **Step 3: Push and create PR**
+
+Run:
+
+```bash
+git push -u origin codex/org-strix-ssrf-integration
+gh pr create --repo ContextualWisdomLab/appguardrail --base develop --head codex/org-strix-ssrf-integration --title "Integrate org Strix patterns and secure log fetches" --body-file /tmp/appguardrail-org-strix-ssrf-pr.md
+```
+
+- [ ] **Step 4: Merge under user policy**
+
+If the PR is locally verified but blocked only by review process or queued checks, merge under the current user policy. If rulesets require temporary relaxation, capture, relax only the needed rules, merge, then restore and diff-check the restored rulesets.
diff --git a/pyproject.toml b/pyproject.toml
index a1a71bd..2251c71 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -49,7 +49,7 @@ version = { attr = "scanner.cli.appguardrail.__version__" }
[tool.setuptools.packages.find]
where = ["."]
-include = ["scanner*"]
+include = ["scanner*", "appguardrail_core*"]
namespaces = false
[tool.setuptools.package-data]
diff --git a/reports/templates/buyer-diligence.md b/reports/templates/buyer-diligence.md
new file mode 100644
index 0000000..53f8544
--- /dev/null
+++ b/reports/templates/buyer-diligence.md
@@ -0,0 +1,23 @@
+# AppGuardrail Buyer Diligence Report
+
+This template is generated from normalized AppGuardrail findings through
+`appguardrail_core.reports.render_buyer_diligence_report`.
+
+Use it when a founder, agency, or acquiring team needs evidence that shows:
+
+- what was scanned,
+- which findings block launch or sale diligence,
+- how findings map to OWASP/CWE/SAMM references,
+- what remediation and verification steps remain,
+- which raw secrets or logs were intentionally excluded from the report.
+
+Required generated sections:
+
+1. Executive Readout
+2. Scope And Evidence Handling
+3. Findings Summary
+4. Detailed Findings
+5. Buyer Follow-Up Checklist
+
+The generated report must not include raw customer secrets, authorization
+headers, JWT values, or full CI logs.
diff --git a/requirements-release.in b/requirements-release.in
new file mode 100644
index 0000000..74a5a49
--- /dev/null
+++ b/requirements-release.in
@@ -0,0 +1,4 @@
+build==1.5.0
+twine==6.2.0
+cyclonedx-bom==7.3.0
+pip-audit==2.10.1
diff --git a/requirements-release.txt b/requirements-release.txt
index 9079c46..a7d5fb5 100644
--- a/requirements-release.txt
+++ b/requirements-release.txt
@@ -1,2 +1,966 @@
-build==1.5.0
-twine==6.2.0
+# This file was autogenerated by uv via the following command:
+# uv pip compile --generate-hashes --python-version 3.13 --universal requirements-release.in -o requirements-release.txt
+arrow==1.4.0 \
+ --hash=sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205 \
+ --hash=sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7
+ # via isoduration
+attrs==26.1.0 \
+ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
+ --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
+ # via
+ # jsonschema
+ # referencing
+boolean-py==5.0 \
+ --hash=sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95 \
+ --hash=sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9
+ # via license-expression
+build==1.5.0 \
+ --hash=sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f \
+ --hash=sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647
+ # via -r requirements-release.in
+cachecontrol==0.14.4 \
+ --hash=sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b \
+ --hash=sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1
+ # via pip-audit
+certifi==2026.6.17 \
+ --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \
+ --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db
+ # via requests
+cffi==2.0.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux' \
+ --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \
+ --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \
+ --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \
+ --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \
+ --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \
+ --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \
+ --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \
+ --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \
+ --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \
+ --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \
+ --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \
+ --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \
+ --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \
+ --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \
+ --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \
+ --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \
+ --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \
+ --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \
+ --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \
+ --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \
+ --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \
+ --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \
+ --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \
+ --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \
+ --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \
+ --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \
+ --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \
+ --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \
+ --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \
+ --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \
+ --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \
+ --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \
+ --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \
+ --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \
+ --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \
+ --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \
+ --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \
+ --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \
+ --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \
+ --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \
+ --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \
+ --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \
+ --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \
+ --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \
+ --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \
+ --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \
+ --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \
+ --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \
+ --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \
+ --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \
+ --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \
+ --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \
+ --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \
+ --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \
+ --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \
+ --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \
+ --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \
+ --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \
+ --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \
+ --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \
+ --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \
+ --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \
+ --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \
+ --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \
+ --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \
+ --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \
+ --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \
+ --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \
+ --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \
+ --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \
+ --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \
+ --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \
+ --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \
+ --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \
+ --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \
+ --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \
+ --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \
+ --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \
+ --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \
+ --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \
+ --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \
+ --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \
+ --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \
+ --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf
+ # via cryptography
+chardet==5.2.0 \
+ --hash=sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7 \
+ --hash=sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970
+ # via cyclonedx-bom
+charset-normalizer==3.4.7 \
+ --hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \
+ --hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \
+ --hash=sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67 \
+ --hash=sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4 \
+ --hash=sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0 \
+ --hash=sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c \
+ --hash=sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5 \
+ --hash=sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444 \
+ --hash=sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153 \
+ --hash=sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9 \
+ --hash=sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01 \
+ --hash=sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217 \
+ --hash=sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b \
+ --hash=sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c \
+ --hash=sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a \
+ --hash=sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83 \
+ --hash=sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5 \
+ --hash=sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7 \
+ --hash=sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb \
+ --hash=sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c \
+ --hash=sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1 \
+ --hash=sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42 \
+ --hash=sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab \
+ --hash=sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df \
+ --hash=sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e \
+ --hash=sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207 \
+ --hash=sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18 \
+ --hash=sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734 \
+ --hash=sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38 \
+ --hash=sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110 \
+ --hash=sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18 \
+ --hash=sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44 \
+ --hash=sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d \
+ --hash=sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48 \
+ --hash=sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e \
+ --hash=sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5 \
+ --hash=sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d \
+ --hash=sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53 \
+ --hash=sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790 \
+ --hash=sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c \
+ --hash=sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b \
+ --hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116 \
+ --hash=sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d \
+ --hash=sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10 \
+ --hash=sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6 \
+ --hash=sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2 \
+ --hash=sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776 \
+ --hash=sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a \
+ --hash=sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265 \
+ --hash=sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008 \
+ --hash=sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943 \
+ --hash=sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374 \
+ --hash=sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246 \
+ --hash=sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e \
+ --hash=sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5 \
+ --hash=sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616 \
+ --hash=sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15 \
+ --hash=sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41 \
+ --hash=sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960 \
+ --hash=sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752 \
+ --hash=sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e \
+ --hash=sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72 \
+ --hash=sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7 \
+ --hash=sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8 \
+ --hash=sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b \
+ --hash=sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4 \
+ --hash=sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545 \
+ --hash=sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706 \
+ --hash=sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366 \
+ --hash=sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb \
+ --hash=sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a \
+ --hash=sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e \
+ --hash=sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00 \
+ --hash=sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f \
+ --hash=sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a \
+ --hash=sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1 \
+ --hash=sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66 \
+ --hash=sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356 \
+ --hash=sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319 \
+ --hash=sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4 \
+ --hash=sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad \
+ --hash=sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d \
+ --hash=sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5 \
+ --hash=sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7 \
+ --hash=sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0 \
+ --hash=sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686 \
+ --hash=sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34 \
+ --hash=sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49 \
+ --hash=sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c \
+ --hash=sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1 \
+ --hash=sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e \
+ --hash=sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60 \
+ --hash=sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0 \
+ --hash=sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274 \
+ --hash=sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d \
+ --hash=sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0 \
+ --hash=sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae \
+ --hash=sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f \
+ --hash=sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d \
+ --hash=sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe \
+ --hash=sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3 \
+ --hash=sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393 \
+ --hash=sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1 \
+ --hash=sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af \
+ --hash=sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44 \
+ --hash=sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00 \
+ --hash=sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c \
+ --hash=sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3 \
+ --hash=sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7 \
+ --hash=sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd \
+ --hash=sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e \
+ --hash=sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b \
+ --hash=sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8 \
+ --hash=sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259 \
+ --hash=sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859 \
+ --hash=sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46 \
+ --hash=sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30 \
+ --hash=sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b \
+ --hash=sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46 \
+ --hash=sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24 \
+ --hash=sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a \
+ --hash=sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24 \
+ --hash=sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc \
+ --hash=sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215 \
+ --hash=sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063 \
+ --hash=sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832 \
+ --hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \
+ --hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \
+ --hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464
+ # via requests
+colorama==0.4.6 ; os_name == 'nt' \
+ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
+ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
+ # via build
+cryptography==49.0.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux' \
+ --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \
+ --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \
+ --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \
+ --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \
+ --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \
+ --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \
+ --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \
+ --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \
+ --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \
+ --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \
+ --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \
+ --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \
+ --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \
+ --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \
+ --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \
+ --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \
+ --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \
+ --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \
+ --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \
+ --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \
+ --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \
+ --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \
+ --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \
+ --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \
+ --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \
+ --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \
+ --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \
+ --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \
+ --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \
+ --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \
+ --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \
+ --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \
+ --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \
+ --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \
+ --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \
+ --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \
+ --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \
+ --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \
+ --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \
+ --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \
+ --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \
+ --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \
+ --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \
+ --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \
+ --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \
+ --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b
+ # via secretstorage
+cyclonedx-bom==7.3.0 \
+ --hash=sha256:73d7d76b3a28ebadc50eabf424cbcf128785a74b8a59ce7893da2e29cfaab585 \
+ --hash=sha256:d54080d65731980945de38e52009a5e5711f4a3c31377a3d13af96eaca5fcf3d
+ # via -r requirements-release.in
+cyclonedx-python-lib==11.11.0 \
+ --hash=sha256:3049fc83e06a059b5c5907a527625a8ed5073caab10607ed4c9e5503b590fd44 \
+ --hash=sha256:4b3194db72b613717f2912447e67ab618c75ff7dcac6c4af3c0e9e1ac617c102
+ # via
+ # cyclonedx-bom
+ # pip-audit
+defusedxml==0.7.1 \
+ --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
+ --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
+ # via py-serializable
+docutils==0.23 \
+ --hash=sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea \
+ --hash=sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e
+ # via readme-renderer
+filelock==3.29.4 \
+ --hash=sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a \
+ --hash=sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767
+ # via cachecontrol
+fqdn==1.5.1 \
+ --hash=sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f \
+ --hash=sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014
+ # via jsonschema
+id==1.6.1 \
+ --hash=sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069 \
+ --hash=sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca
+ # via twine
+idna==3.18 \
+ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
+ --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
+ # via
+ # jsonschema
+ # requests
+isoduration==20.11.0 \
+ --hash=sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9 \
+ --hash=sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042
+ # via jsonschema
+jaraco-classes==3.4.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' \
+ --hash=sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd \
+ --hash=sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790
+ # via keyring
+jaraco-context==6.1.2 ; platform_machine != 'ppc64le' and platform_machine != 's390x' \
+ --hash=sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535 \
+ --hash=sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3
+ # via keyring
+jaraco-functools==4.5.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' \
+ --hash=sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03 \
+ --hash=sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4
+ # via keyring
+jeepney==0.9.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux' \
+ --hash=sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683 \
+ --hash=sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732
+ # via
+ # keyring
+ # secretstorage
+jsonpointer==3.1.1 \
+ --hash=sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900 \
+ --hash=sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca
+ # via jsonschema
+jsonschema==4.26.0 \
+ --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \
+ --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce
+ # via cyclonedx-python-lib
+jsonschema-specifications==2025.9.1 \
+ --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \
+ --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d
+ # via jsonschema
+keyring==25.7.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' \
+ --hash=sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f \
+ --hash=sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b
+ # via twine
+lark==1.3.1 \
+ --hash=sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905 \
+ --hash=sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12
+ # via rfc3987-syntax
+license-expression==30.4.4 \
+ --hash=sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4 \
+ --hash=sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd
+ # via cyclonedx-python-lib
+lxml==6.1.1 \
+ --hash=sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2 \
+ --hash=sha256:07a4a68e286ee7a1ed7dfb8af83e615757c0ccfe9f18c6b4ea6771388d9ba8c9 \
+ --hash=sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60 \
+ --hash=sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c \
+ --hash=sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7 \
+ --hash=sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a \
+ --hash=sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83 \
+ --hash=sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072 \
+ --hash=sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8 \
+ --hash=sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462 \
+ --hash=sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0 \
+ --hash=sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085 \
+ --hash=sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f \
+ --hash=sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30 \
+ --hash=sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1 \
+ --hash=sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77 \
+ --hash=sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740 \
+ --hash=sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b \
+ --hash=sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c \
+ --hash=sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621 \
+ --hash=sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e \
+ --hash=sha256:32ab449a5486f6c758e849bb86710d0e45edc24a04e250c01555f8f5653958f8 \
+ --hash=sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca \
+ --hash=sha256:34c2d737beabfe35baada43941ed519251e9a12e779031496bcd5d539fcfd730 \
+ --hash=sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245 \
+ --hash=sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1 \
+ --hash=sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004 \
+ --hash=sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d \
+ --hash=sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52 \
+ --hash=sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5 \
+ --hash=sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf \
+ --hash=sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc \
+ --hash=sha256:441dd227fa0690eb9fc81edabc63cdcefc212bba99b906dcf6e32cc1a9d3e533 \
+ --hash=sha256:469e3618338bd7ab5beb412d2439825479fcf0dab99e394ca563dbc4eaf6c834 \
+ --hash=sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947 \
+ --hash=sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a \
+ --hash=sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2 \
+ --hash=sha256:53c909b62a0532183542fed00c5a7218258c56292d409bc789886fe1cb04c438 \
+ --hash=sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc \
+ --hash=sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea \
+ --hash=sha256:55b03549819867ea141c0202242c4816c82e52ec36e7e648db9d8da5a3dc3ed6 \
+ --hash=sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e \
+ --hash=sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c \
+ --hash=sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383 \
+ --hash=sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955 \
+ --hash=sha256:5bec7d03d78d853597d6107854c2310ce3f761fd218fe9fe91d5101fcf6c2efe \
+ --hash=sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074 \
+ --hash=sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c \
+ --hash=sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a \
+ --hash=sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb \
+ --hash=sha256:639f6c857d91d9be29bd7502348d6736dab168b54b5158cd899abf11684dc186 \
+ --hash=sha256:640f97d43d867bcb9c75b3af013b64850756b746cb6bce8ace83b70da3abba9d \
+ --hash=sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1 \
+ --hash=sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f \
+ --hash=sha256:6689e828a94eee4f139408c337bb198e014724bb8a8c26d3cfac49d119ed69a6 \
+ --hash=sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736 \
+ --hash=sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6 \
+ --hash=sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2 \
+ --hash=sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b \
+ --hash=sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7 \
+ --hash=sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14 \
+ --hash=sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009 \
+ --hash=sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca \
+ --hash=sha256:76447f65250ed2501ead1a1552f5ce8edff159a86f308348e6a9c4acb5e1f1b4 \
+ --hash=sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635 \
+ --hash=sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee \
+ --hash=sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e \
+ --hash=sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9 \
+ --hash=sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603 \
+ --hash=sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08 \
+ --hash=sha256:83b6b30eb131da7a75b601f28c5d6971e6ed3e887919bf6b6a1ad3c2df289080 \
+ --hash=sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525 \
+ --hash=sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5 \
+ --hash=sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f \
+ --hash=sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067 \
+ --hash=sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e \
+ --hash=sha256:8be8ad51249698103d24b0571df35a10990fbe93dd043b6c024172189485f5e3 \
+ --hash=sha256:8d43ca737b20e106e4aebc42b2f3ae19f00ba63d7eb731698ee083d72d15646f \
+ --hash=sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485 \
+ --hash=sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13 \
+ --hash=sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383 \
+ --hash=sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315 \
+ --hash=sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e \
+ --hash=sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c \
+ --hash=sha256:9f76acfb5f68ba982635a53fd985a8044be98a35b43232c2a1ee235ffab3e1dd \
+ --hash=sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099 \
+ --hash=sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660 \
+ --hash=sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510 \
+ --hash=sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a \
+ --hash=sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b \
+ --hash=sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5 \
+ --hash=sha256:aae97dfdb60715c164419ac2532a76d013c3918a665eb6cb7288098b5f349aaf \
+ --hash=sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28 \
+ --hash=sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00 \
+ --hash=sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef \
+ --hash=sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1 \
+ --hash=sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955 \
+ --hash=sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590 \
+ --hash=sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137 \
+ --hash=sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf \
+ --hash=sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40 \
+ --hash=sha256:bdebcc8a75d38c7598dfb2c9ed852d7a9eb4a10d6e2d0764b919b802bf32ac88 \
+ --hash=sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e \
+ --hash=sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840 \
+ --hash=sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2 \
+ --hash=sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca \
+ --hash=sha256:c674693f055fa2495de12292cb45e9944199d8eaef5a2dec45175c7c61cb73e3 \
+ --hash=sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465 \
+ --hash=sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc \
+ --hash=sha256:c9a4b821dc7055bf9e05ff5719e18ec501f75c0f0bbfabd573b277559780833d \
+ --hash=sha256:c9f79d5325907f13e1be0b3e4dacc1049d1dffc4aeee3c995284bea5fe0fab7d \
+ --hash=sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa \
+ --hash=sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a \
+ --hash=sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a \
+ --hash=sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206 \
+ --hash=sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e \
+ --hash=sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785 \
+ --hash=sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8 \
+ --hash=sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a \
+ --hash=sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b \
+ --hash=sha256:e07c65f443c887bbcf31cc1771d932ecc192a5273943589b3c7572b749f1ffb2 \
+ --hash=sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6 \
+ --hash=sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6 \
+ --hash=sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354 \
+ --hash=sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818 \
+ --hash=sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84 \
+ --hash=sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909 \
+ --hash=sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038 \
+ --hash=sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d \
+ --hash=sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2 \
+ --hash=sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf \
+ --hash=sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc \
+ --hash=sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d \
+ --hash=sha256:ffecec8eb889b58ba9be5b95fb1cc78e22ea8eedea38e8736a1568fe1979250e
+ # via cyclonedx-python-lib
+markdown-it-py==4.2.0 \
+ --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \
+ --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a
+ # via rich
+mdurl==0.1.2 \
+ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
+ --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
+ # via markdown-it-py
+more-itertools==11.1.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' \
+ --hash=sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d \
+ --hash=sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192
+ # via
+ # jaraco-classes
+ # jaraco-functools
+msgpack==1.2.1 \
+ --hash=sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833 \
+ --hash=sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a \
+ --hash=sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647 \
+ --hash=sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d \
+ --hash=sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064 \
+ --hash=sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107 \
+ --hash=sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac \
+ --hash=sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720 \
+ --hash=sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c \
+ --hash=sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06 \
+ --hash=sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895 \
+ --hash=sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde \
+ --hash=sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203 \
+ --hash=sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc \
+ --hash=sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74 \
+ --hash=sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22 \
+ --hash=sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8 \
+ --hash=sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35 \
+ --hash=sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb \
+ --hash=sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9 \
+ --hash=sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8 \
+ --hash=sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6 \
+ --hash=sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07 \
+ --hash=sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056 \
+ --hash=sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4 \
+ --hash=sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7 \
+ --hash=sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1 \
+ --hash=sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24 \
+ --hash=sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355 \
+ --hash=sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0 \
+ --hash=sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce \
+ --hash=sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f \
+ --hash=sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707 \
+ --hash=sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66 \
+ --hash=sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b \
+ --hash=sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e \
+ --hash=sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d \
+ --hash=sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8 \
+ --hash=sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7 \
+ --hash=sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73 \
+ --hash=sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402 \
+ --hash=sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a \
+ --hash=sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d \
+ --hash=sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c \
+ --hash=sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273 \
+ --hash=sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b \
+ --hash=sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d \
+ --hash=sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb \
+ --hash=sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4 \
+ --hash=sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190 \
+ --hash=sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5 \
+ --hash=sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a \
+ --hash=sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7 \
+ --hash=sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1 \
+ --hash=sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889 \
+ --hash=sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c \
+ --hash=sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155 \
+ --hash=sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24 \
+ --hash=sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6 \
+ --hash=sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1 \
+ --hash=sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d \
+ --hash=sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7 \
+ --hash=sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64 \
+ --hash=sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2 \
+ --hash=sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc \
+ --hash=sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c
+ # via cachecontrol
+nh3==0.3.6 \
+ --hash=sha256:082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56 \
+ --hash=sha256:2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2 \
+ --hash=sha256:25c733bee928530556b1db0ea46c52cf5aa686146e38e60a6fc7cb801ef91cec \
+ --hash=sha256:2f90d9a0cfdbee218994fdaaeeb5a0fde62d08f35e4eef0378ec1e2200172fd0 \
+ --hash=sha256:34d2b0d934156b87ee114f599a3ba9b8b9e17b5d79652ba3a13fa50903de965e \
+ --hash=sha256:36d06341bd501240d320f5942481ed5e6846136b666e1ba4faf802b78ebc875f \
+ --hash=sha256:43bc1ed3fa0716295fabee29ba42b2667e4a51d140b0a68e092170a765474fa6 \
+ --hash=sha256:44673b27010051ab5a5e438a86ec31bbda61d4a77d7e900af6b7be3037c1abae \
+ --hash=sha256:455469a29951edc92bc48b47ac2281c3f2609e6c4f6a047056449f8c2c23facf \
+ --hash=sha256:4713502748f564fee0633b37b3403783ce0a3af3a3d148ad91025a5bdadb7bc6 \
+ --hash=sha256:5276ef17bdba9ad8040575c74072008b13aae429436e9d0429e718bb5f90f4da \
+ --hash=sha256:597a8e843bea00b2eb5520658dc24a9bb032e7fc9e7c2c0c4cd29420220c9796 \
+ --hash=sha256:69bbb92865a693d909db3a700d3c01537533844d0948c1e9323561ce06ecda41 \
+ --hash=sha256:69f365963f63a1e9bff53bdbb3c542c7c2efed3e163c9d5d83a772a2ac468c21 \
+ --hash=sha256:82ca5bf427ad1b216b65ede1a2e2d87dc49bec417ceba0f297213107d3cd9d78 \
+ --hash=sha256:889932a97fb4abb6f95fef1914c0d269ebfb60011e67121c1163059b9449dbb4 \
+ --hash=sha256:905f877dc66dd7aea4a76e54bcb26acb5ff8216f720c0017ccf63e0e6035698e \
+ --hash=sha256:a43ebd7543555c3ac1bc353023d0794e75cb76f6f18f19c32e95441496c0cc25 \
+ --hash=sha256:d14bf7982e7a77c0c775634c29c07ce08b38a046df73e1c1f139b3e82f18a38e \
+ --hash=sha256:e196fa70c2ff2eb4de7d3df3108f8f358c1d69dff20d45b11f20a5aa227ffb6d \
+ --hash=sha256:e1b160831c9cdb06a6c79c2f9cdb11386602938f9af260d1c457a85add4f6f69 \
+ --hash=sha256:e6b7beece07525dc6e6b0fc2f104442de2ba328360ad00e50cbe2e1fd620447d \
+ --hash=sha256:edb2b4a1a27523e6cc7c417f8d21ce3d005243548b93e56b762b66b0c7f589f9 \
+ --hash=sha256:f2f14b7ae1fca99c4a66c981aac3974e7fbc1ca30a12673d223ae1df76680917 \
+ --hash=sha256:f338ac7d594c067679f1e99b4f5ec3906842979560f9d8f15d6bdfa39a353b10 \
+ --hash=sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7 \
+ --hash=sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438
+ # via readme-renderer
+packageurl-python==0.17.6 \
+ --hash=sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25 \
+ --hash=sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9
+ # via
+ # cyclonedx-bom
+ # cyclonedx-python-lib
+packaging==26.2 \
+ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
+ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
+ # via
+ # build
+ # cyclonedx-bom
+ # pip-audit
+ # pip-requirements-parser
+ # twine
+pip==26.1.2 \
+ --hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \
+ --hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605
+ # via pip-api
+pip-api==0.0.34 \
+ --hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb \
+ --hash=sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625
+ # via pip-audit
+pip-audit==2.10.1 \
+ --hash=sha256:1eb4565d19ebe5d48996f4b770b4d2b32887e12cb12cfa637f1a064011b55ffc \
+ --hash=sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a
+ # via -r requirements-release.in
+pip-requirements-parser==32.0.1 \
+ --hash=sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526 \
+ --hash=sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3
+ # via
+ # cyclonedx-bom
+ # pip-audit
+platformdirs==4.10.0 \
+ --hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \
+ --hash=sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a
+ # via pip-audit
+py-serializable==2.1.0 \
+ --hash=sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103 \
+ --hash=sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304
+ # via cyclonedx-python-lib
+pycparser==3.0 ; implementation_name != 'PyPy' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux' \
+ --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
+ --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
+ # via cffi
+pygments==2.20.0 \
+ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
+ --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
+ # via
+ # readme-renderer
+ # rich
+pyparsing==3.3.2 \
+ --hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \
+ --hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc
+ # via pip-requirements-parser
+pyproject-hooks==1.2.0 \
+ --hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \
+ --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913
+ # via build
+python-dateutil==2.9.0.post0 \
+ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
+ --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
+ # via arrow
+pywin32-ctypes==0.2.3 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'win32' \
+ --hash=sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8 \
+ --hash=sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755
+ # via keyring
+readme-renderer==45.0 \
+ --hash=sha256:030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1 \
+ --hash=sha256:3385ed220117104a2bceb4a9dac8c5fdf6d1f96890d7ea2a9c7174fd5c84091f
+ # via twine
+referencing==0.37.0 \
+ --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \
+ --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8
+ # via
+ # cyclonedx-python-lib
+ # jsonschema
+ # jsonschema-specifications
+requests==2.34.2 \
+ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
+ --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
+ # via
+ # cachecontrol
+ # pip-audit
+ # requests-toolbelt
+ # twine
+requests-toolbelt==1.0.0 \
+ --hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \
+ --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06
+ # via twine
+rfc3339-validator==0.1.4 \
+ --hash=sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b \
+ --hash=sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa
+ # via jsonschema
+rfc3986==2.0.0 \
+ --hash=sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd \
+ --hash=sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c
+ # via twine
+rfc3986-validator==0.1.1 \
+ --hash=sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9 \
+ --hash=sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055
+ # via jsonschema
+rfc3987-syntax==1.1.0 \
+ --hash=sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f \
+ --hash=sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d
+ # via jsonschema
+rich==15.0.0 \
+ --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \
+ --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36
+ # via
+ # pip-audit
+ # twine
+rpds-py==2026.5.1 \
+ --hash=sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead \
+ --hash=sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a \
+ --hash=sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4 \
+ --hash=sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256 \
+ --hash=sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb \
+ --hash=sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b \
+ --hash=sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870 \
+ --hash=sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc \
+ --hash=sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08 \
+ --hash=sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251 \
+ --hash=sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473 \
+ --hash=sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b \
+ --hash=sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a \
+ --hash=sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131 \
+ --hash=sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9 \
+ --hash=sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01 \
+ --hash=sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba \
+ --hash=sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad \
+ --hash=sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db \
+ --hash=sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d \
+ --hash=sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0 \
+ --hash=sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63 \
+ --hash=sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee \
+ --hash=sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7 \
+ --hash=sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b \
+ --hash=sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036 \
+ --hash=sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb \
+ --hash=sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16 \
+ --hash=sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f \
+ --hash=sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d \
+ --hash=sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d \
+ --hash=sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5 \
+ --hash=sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78 \
+ --hash=sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66 \
+ --hash=sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972 \
+ --hash=sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd \
+ --hash=sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89 \
+ --hash=sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732 \
+ --hash=sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02 \
+ --hash=sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef \
+ --hash=sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a \
+ --hash=sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c \
+ --hash=sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723 \
+ --hash=sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda \
+ --hash=sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7 \
+ --hash=sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca \
+ --hash=sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02 \
+ --hash=sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015 \
+ --hash=sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1 \
+ --hash=sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed \
+ --hash=sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00 \
+ --hash=sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a \
+ --hash=sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195 \
+ --hash=sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a \
+ --hash=sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa \
+ --hash=sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece \
+ --hash=sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df \
+ --hash=sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26 \
+ --hash=sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa \
+ --hash=sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842 \
+ --hash=sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a \
+ --hash=sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c \
+ --hash=sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd \
+ --hash=sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a \
+ --hash=sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf \
+ --hash=sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2 \
+ --hash=sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f \
+ --hash=sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf \
+ --hash=sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049 \
+ --hash=sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3 \
+ --hash=sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964 \
+ --hash=sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291 \
+ --hash=sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14 \
+ --hash=sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc \
+ --hash=sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47 \
+ --hash=sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5 \
+ --hash=sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d \
+ --hash=sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb \
+ --hash=sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df \
+ --hash=sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a \
+ --hash=sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc \
+ --hash=sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc \
+ --hash=sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46 \
+ --hash=sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb \
+ --hash=sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2 \
+ --hash=sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e \
+ --hash=sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb \
+ --hash=sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec \
+ --hash=sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325 \
+ --hash=sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600 \
+ --hash=sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559 \
+ --hash=sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41 \
+ --hash=sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644 \
+ --hash=sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b \
+ --hash=sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162 \
+ --hash=sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83 \
+ --hash=sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038 \
+ --hash=sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6 \
+ --hash=sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b \
+ --hash=sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3 \
+ --hash=sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9 \
+ --hash=sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34 \
+ --hash=sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6 \
+ --hash=sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb \
+ --hash=sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa \
+ --hash=sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6 \
+ --hash=sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d \
+ --hash=sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24 \
+ --hash=sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838 \
+ --hash=sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164 \
+ --hash=sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97 \
+ --hash=sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4 \
+ --hash=sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2 \
+ --hash=sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55 \
+ --hash=sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3 \
+ --hash=sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2 \
+ --hash=sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358 \
+ --hash=sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b \
+ --hash=sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8 \
+ --hash=sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0 \
+ --hash=sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea \
+ --hash=sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081 \
+ --hash=sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d \
+ --hash=sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1 \
+ --hash=sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81 \
+ --hash=sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3 \
+ --hash=sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8 \
+ --hash=sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1 \
+ --hash=sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0 \
+ --hash=sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd
+ # via
+ # jsonschema
+ # referencing
+secretstorage==3.5.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux' \
+ --hash=sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137 \
+ --hash=sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be
+ # via keyring
+six==1.17.0 \
+ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
+ --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
+ # via
+ # python-dateutil
+ # rfc3339-validator
+sortedcontainers==2.4.0 \
+ --hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \
+ --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0
+ # via cyclonedx-python-lib
+tomli==2.4.1 \
+ --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \
+ --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \
+ --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \
+ --hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \
+ --hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \
+ --hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \
+ --hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \
+ --hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \
+ --hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \
+ --hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \
+ --hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \
+ --hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \
+ --hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \
+ --hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \
+ --hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \
+ --hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \
+ --hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \
+ --hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \
+ --hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \
+ --hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \
+ --hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \
+ --hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \
+ --hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \
+ --hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \
+ --hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \
+ --hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \
+ --hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \
+ --hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \
+ --hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \
+ --hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \
+ --hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \
+ --hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \
+ --hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \
+ --hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \
+ --hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \
+ --hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \
+ --hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \
+ --hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \
+ --hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \
+ --hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \
+ --hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \
+ --hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \
+ --hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \
+ --hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \
+ --hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \
+ --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \
+ --hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049
+ # via pip-audit
+tomli-w==1.2.0 \
+ --hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \
+ --hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021
+ # via pip-audit
+twine==6.2.0 \
+ --hash=sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8 \
+ --hash=sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf
+ # via -r requirements-release.in
+tzdata==2026.2 \
+ --hash=sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10 \
+ --hash=sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7
+ # via arrow
+uri-template==1.3.0 \
+ --hash=sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7 \
+ --hash=sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363
+ # via jsonschema
+urllib3==2.7.0 \
+ --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
+ --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
+ # via
+ # id
+ # requests
+ # twine
+webcolors==25.10.0 \
+ --hash=sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d \
+ --hash=sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf
+ # via jsonschema
diff --git a/scanner/cli/appguardrail.py b/scanner/cli/appguardrail.py
index 13e479f..3f2d317 100644
--- a/scanner/cli/appguardrail.py
+++ b/scanner/cli/appguardrail.py
@@ -4,9 +4,11 @@
Usage:
appguardrail init [--tool ] [--stack ]
- appguardrail scan [--trivy] [--external auto|off] [--bandit] [--ruff] [--semgrep] [--zap-baseline ] [--codegraph] []
+ appguardrail scan [--trivy] [--external auto|off] [--bandit] [--ruff] [--semgrep] [--zap-baseline ] [--findings-json ] [--codegraph] []
appguardrail monitor
appguardrail review [--stack ] [--db ] [--payments ]
+ appguardrail report {buyer-diligence,founder-friendly,agency,fix-pack} --findings [--out ]
+ appguardrail org-bundle [--owner ] [--bundle-dir ]
appguardrail hook [--codegraph]
appguardrail --help
appguardrail --version
@@ -16,6 +18,8 @@
scan Run a lightweight security scan on a directory
monitor Install a GitHub Actions monitor workflow
review Generate an AI review prompt for your stack
+ report Generate product and diligence reports from findings JSON
+ org-bundle Generate an organization buyer evidence bundle
hook Install a pre-commit hook to block vulnerabilities
Options:
@@ -29,6 +33,7 @@
--ruff Force-run Ruff Bandit-compatible security rules
--semgrep Force-run Semgrep multi-language SAST
--zap-baseline Run OWASP ZAP baseline scan against a URL
+ --findings-json Write normalized findings JSON for reports or dashboards
--codegraph Initialize or sync a CodeGraph index before scanning
--help Show this help message
--version Show version
@@ -48,6 +53,27 @@
import tempfile
from pathlib import Path
+if __package__ in (None, ""):
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+
+from appguardrail_core.external import build_external_scan_plan
+from appguardrail_core.findings import NON_BLOCKING_CONTEXTS
+from appguardrail_core.findings import \
+ is_deploy_blocking as core_is_deploy_blocking
+from appguardrail_core.findings import normalize_findings
+from appguardrail_core.language import (LANGUAGE_EXTENSIONS,
+ detect_language_axes,
+ detect_stack_profile)
+from appguardrail_core.org_bundle import (OrgBundleError,
+ annotate_missing_pr_repositories,
+ gh_error_message, gh_pr_list,
+ gh_repo_list)
+from appguardrail_core.org_bundle import load_json as load_org_json
+from appguardrail_core.org_bundle import render_org_evidence, write_bundle
+from appguardrail_core.reports import (REPORT_TYPE_LABELS, ReportContext,
+ render_report, supported_report_types)
+from appguardrail_core.rules import build_rule_metadata
+
__version__ = "0.1.1"
# ---------------------------------------------------------------------------
@@ -603,6 +629,94 @@
"message": "Path validation checks '..' parts but does not reject absolute paths nearby. Reject absolute paths and verify resolved paths stay under the allowed root. [OWASP A01:2021 - Broken Access Control]",
"extensions": [".py"],
},
+ {
+ "id": "python-okta-host-endswith-ssrf",
+ "pattern": re.compile(
+ r"(?is)\b(?:authenticator|hostname|netloc|parsed_url|parsed)\b(?:(?!\n\s*\n).){0,500}\.endswith\s*\(\s*(?:\([^\)]*)?[\"']\.?(?:okta|oktapreview)\.com[\"']"
+ ),
+ "severity": "HIGH",
+ "message": "Okta/Snowflake authenticator host validation uses a suffix check. Parse the URL hostname and allow only exact Okta domains or verified subdomains to prevent SSRF bypasses. [OWASP A10:2021 - Server-Side Request Forgery]",
+ "extensions": [".py"],
+ },
+ {
+ "id": "python-subprocess-missing-timeout",
+ "pattern": re.compile(
+ r"(?is)(?:subprocess\.(?:run|Popen|call|check_call|check_output)\s*\((?!(?:(?!\n\s*\)\s*(?:\n|$)).)*timeout\s*=)(?:(?!\n\s*\)\s*(?:\n|$)).){0,1200}\n\s*\)|subprocess\.(?:run|Popen|call|check_call|check_output)\s*\((?:(?!timeout\s*=)[^\n])+\))"
+ ),
+ "severity": "HIGH",
+ "message": "External process call has no timeout. Add a bounded timeout and handle TimeoutExpired to prevent worker exhaustion. [OWASP A04:2021 - Insecure Design]",
+ "extensions": [".py"],
+ },
+ {
+ "id": "shell-awk-variable-injection",
+ "pattern": re.compile(
+ r"(?is)\bawk\s+(?:[\"'][^\"'\n]{0,300}\$\{?[A-Za-z_][A-Za-z0-9_]*\}?[^\"'\n]{0,300}[\"']|[\"'][^\"'\n]{0,300}[\"']\s*\"\$[A-Za-z_][A-Za-z0-9_]*\")"
+ ),
+ "severity": "CRITICAL",
+ "message": "Shell variable is interpolated into an awk program. Validate input and pass values with awk -v instead of embedding shell variables in the awk script. [OWASP A03:2021 - Injection]",
+ "extensions": [".sh", ".bash"],
+ },
+ {
+ "id": "node-exec-url-command-injection",
+ "pattern": re.compile(
+ r"(?i)\bexec(?:Sync)?\s*\(\s*(?:authUrl|browserUrl|openUrl|url|command)\b"
+ ),
+ "severity": "CRITICAL",
+ "message": "child_process.exec is called with a URL or command variable. Use spawn/execFile with argument arrays and validate allowed URL protocols. [OWASP A03:2021 - Injection]",
+ "extensions": [".ts", ".tsx", ".js", ".jsx"],
+ },
+ {
+ "id": "node-unvalidated-output-path-write",
+ "pattern": re.compile(
+ r"(?i)\b(?:writeFile|writeFileSync|createWriteStream)\s*\(\s*(?:output|outputPath|filePath|dest|destination|exportPath)\b"
+ ),
+ "severity": "HIGH",
+ "message": "File write uses a caller-controlled output path. Resolve the target and verify it stays inside the allowed project root before writing. [OWASP A01:2021 - Broken Access Control]",
+ "extensions": [".ts", ".tsx", ".js", ".jsx"],
+ },
+ {
+ "id": "python-expanduser-user-path-traversal",
+ "pattern": re.compile(
+ r"(?i)\bPath\s*\([^)]*(?:input|output|file|path)[^)]*\)\.expanduser\s*\("
+ ),
+ "severity": "HIGH",
+ "message": "User-controlled path is expanded before containment validation. Reject traversal and verify resolved paths stay under the allowed root. [OWASP A01:2021 - Broken Access Control]",
+ "extensions": [".py"],
+ },
+ {
+ "id": "github-actions-secret-env-passthrough",
+ "pattern": re.compile(
+ r"(?is)\b(?:LLM_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|GEMINI_API_KEY|DB_PASS|DATABASE_URL|PRIVATE_KEY|ACCESS_TOKEN)\s*:\s*\$\{\{\s*secrets\."
+ ),
+ "severity": "HIGH",
+ "message": "GitHub Actions passes a high-risk secret directly through environment variables. Prefer file-based secret handoff or a scoped platform token. [OWASP A03:2021 - Injection]",
+ "extensions": [".yml", ".yaml"],
+ },
+ {
+ "id": "github-actions-secrets-github-token",
+ "pattern": re.compile(r"\$\{\{\s*secrets\.GITHUB_TOKEN\s*\}\}", re.IGNORECASE),
+ "severity": "HIGH",
+ "message": "Workflow references secrets.GITHUB_TOKEN. Use github.token with least job permissions instead of secret-context token interpolation. [OWASP A05:2021 - Security Misconfiguration]",
+ "extensions": [".yml", ".yaml"],
+ },
+ {
+ "id": "docker-cli-secret-env-leak",
+ "pattern": re.compile(
+ r"(?i)\bdocker\s+(?:run|exec|compose)[^\n]*(?:-e|--env)\s+(?:DB_PASS|DATABASE_URL|PASSWORD|TOKEN|[A-Z0-9_]*SECRET)[A-Z0-9_]*="
+ ),
+ "severity": "HIGH",
+ "message": "Docker command passes a secret through CLI environment flags where it can leak through process listings. Use --env-file or secret mounts. [OWASP A07:2021 - Identification and Authentication Failures]",
+ "extensions": [".sh", ".bash", ".yml", ".yaml"],
+ },
+ {
+ "id": "html-target-blank-without-noopener",
+ "pattern": re.compile(
+ r"(?i)\n]*target\s*=\s*[\"']_blank[\"'])(?![^>\n]*rel\s*=\s*[\"'][^\"']*(?:noopener|noreferrer))[^>\n]*href\s*=\s*[\"']https?://"
+ ),
+ "severity": "WARNING",
+ "message": 'External target=_blank link is missing rel="noopener noreferrer". Add rel attributes to prevent reverse tabnabbing. [OWASP A05:2021 - Security Misconfiguration]',
+ "extensions": [".html", ".htm"],
+ },
{
"id": "python-requests-verify-false",
"pattern": re.compile(
@@ -748,20 +862,6 @@
},
]
-LANGUAGE_EXTENSIONS = {
- "javascript": [".js", ".jsx", ".mjs", ".cjs"],
- "typescript": [".ts", ".tsx", ".mts", ".cts"],
- "java": [".java"],
- "python": [".py"],
- "web": [".html", ".htm"],
-}
-
-LANGUAGE_BY_EXTENSION = {
- extension: language
- for language, extensions in LANGUAGE_EXTENSIONS.items()
- for extension in extensions
-}
-
def _unquote_rule_scalar(value: str) -> str:
"""Return a simple YAML scalar value from the controlled rule files."""
@@ -963,9 +1063,6 @@ def _load_packaged_regex_rules():
".log",
}
-NON_BLOCKING_CONTEXTS = {"doc", "test", "example", "scanner-fixture"}
-DEPLOY_BLOCKING_SEVERITIES = {"CRITICAL", "HIGH"}
-
# ---------------------------------------------------------------------------
# Review prompt templates
# ---------------------------------------------------------------------------
@@ -1168,12 +1265,7 @@ def _print_supabase_reminder():
def _detect_scan_languages(files):
"""Return language axes found in a scan target without requiring a profile."""
- languages = set()
- for file_path in files:
- language = LANGUAGE_BY_EXTENSION.get(file_path.suffix.lower())
- if language:
- languages.add(language)
- return languages
+ return detect_language_axes(files)
def _external_tool_available(name: str, version_args=("--version",)):
@@ -1182,7 +1274,7 @@ def _external_tool_available(name: str, version_args=("--version",)):
if not executable:
return None
try:
- process = subprocess.run(
+ process = subprocess.run( # noqa: S603 - executable resolved with shutil.which
[executable, *version_args],
shell=False,
capture_output=True,
@@ -1197,6 +1289,21 @@ def _external_tool_available(name: str, version_args=("--version",)):
return executable
+def _print_external_auto_skips(plan):
+ """Print beginner-safe auto-mode skips without failing the scan."""
+ skipped = [
+ decision
+ for decision in plan.decisions
+ if decision.skip_reason and not decision.forced
+ ]
+ if not skipped:
+ return
+ print("⚙️ External auto mode:")
+ for decision in skipped:
+ print(f" Skipped {decision.display_name}: {decision.skip_reason}")
+ print()
+
+
def cmd_scan(args):
"""Run a lightweight security scan."""
scan_arg = Path(getattr(args, "path", ".") or ".")
@@ -1212,6 +1319,7 @@ def cmd_scan(args):
zap_baseline_url = getattr(args, "zap_baseline", None) or os.environ.get(
"APPGUARDRAIL_TARGET_URL"
)
+ findings_json = getattr(args, "findings_json", None)
force_zap = bool(getattr(args, "zap_baseline", None))
run_codegraph = getattr(args, "codegraph", False)
@@ -1246,45 +1354,47 @@ def cmd_scan(args):
findings = []
files_scanned = 0
+ scanned_files = []
if scan_path.is_file():
files_to_scan = [scan_path]
else:
files_to_scan = _collect_files(scan_path)
- languages = set()
for file_path in files_to_scan:
- language = LANGUAGE_BY_EXTENSION.get(file_path.suffix.lower())
- if language:
- languages.add(language)
+ scanned_files.append(file_path)
files_scanned += 1
file_findings = _scan_file(file_path, scan_path)
findings.extend(file_findings)
- if languages:
- print(f"🧩 Detected language axes: {', '.join(sorted(languages))}\n")
+ profile = detect_stack_profile(scanned_files)
+ languages = set(profile.languages)
+ if profile.languages:
+ print(f"🧩 Detected language axes: {', '.join(profile.languages)}")
+ print(f"🧭 Beginner profile: {profile.display_name}")
+ print(f" {profile.beginner_summary}")
+ if profile.frameworks:
+ print(f" Framework signals: {', '.join(profile.frameworks)}")
+ if profile.external_tools:
+ print(f" Optional external engines: {', '.join(profile.external_tools)}")
+ if profile.zap_recommended:
+ print(" ZAP baseline: provide --zap-baseline for authorized DAST")
+ print()
- auto_external = external_mode == "auto"
- auto_bandit = (
- auto_external and "python" in languages and _external_tool_available("bandit")
- )
- auto_ruff = (
- auto_external and "python" in languages and _external_tool_available("ruff")
- )
- auto_semgrep = (
- auto_external
- and bool(languages & {"java", "javascript", "python", "typescript", "web"})
- and _external_tool_available("semgrep")
+ external_plan = build_external_scan_plan(
+ languages,
+ external_mode=external_mode,
+ force_trivy=run_trivy,
+ force_bandit=force_bandit,
+ force_ruff=force_ruff,
+ force_semgrep=force_semgrep,
+ zap_baseline_url=zap_baseline_url,
+ force_zap=force_zap,
+ tool_available=_external_tool_available,
)
- auto_zap = bool(zap_baseline_url) and (
- auto_external and _external_tool_available("zap-baseline.py", ("-h",))
- )
- run_bandit = force_bandit or auto_bandit
- run_ruff = force_ruff or auto_ruff
- run_semgrep = force_semgrep or auto_semgrep
- run_zap = bool(zap_baseline_url) and (force_zap or auto_zap)
+ _print_external_auto_skips(external_plan)
- if run_trivy:
+ if external_plan.trivy.should_run:
print("🔎 Trivy FS enabled: vuln, secret, misconfig\n")
try:
findings.extend(_run_trivy_fs(scan_path))
@@ -1296,72 +1406,101 @@ def cmd_scan(args):
)
return 1
- if run_bandit:
+ if external_plan.bandit.should_run:
print("🐍 Bandit enabled: Python SAST\n")
try:
findings.extend(_run_bandit_scan(scan_path))
except RuntimeError as exc:
- if auto_bandit and not force_bandit:
+ if external_plan.bandit.auto_selected and not external_plan.bandit.forced:
print(f"⚠️ Skipping Bandit auto integration: {exc}\n")
else:
print(f"❌ Error: {exc}", file=sys.stderr)
print(
- "💡 Hint: Install Bandit or run without --bandit.",
+ f"💡 Hint: {external_plan.bandit.hint}",
file=sys.stderr,
)
return 1
- if run_ruff:
+ if external_plan.ruff.should_run:
print("🐍 Ruff security rules enabled: select S\n")
try:
findings.extend(_run_ruff_security_scan(scan_path))
except RuntimeError as exc:
- if auto_ruff and not force_ruff:
+ if external_plan.ruff.auto_selected and not external_plan.ruff.forced:
print(f"⚠️ Skipping Ruff auto integration: {exc}\n")
else:
print(f"❌ Error: {exc}", file=sys.stderr)
print(
- "💡 Hint: Install Ruff or run without --ruff.",
+ f"💡 Hint: {external_plan.ruff.hint}",
file=sys.stderr,
)
return 1
- if run_semgrep:
+ if external_plan.semgrep.should_run:
print(f"🔎 Semgrep enabled: config {semgrep_config}\n")
try:
findings.extend(_run_semgrep_scan(scan_path, semgrep_config))
except RuntimeError as exc:
- if auto_semgrep and not force_semgrep:
+ if external_plan.semgrep.auto_selected and not external_plan.semgrep.forced:
print(f"⚠️ Skipping Semgrep auto integration: {exc}\n")
else:
print(f"❌ Error: {exc}", file=sys.stderr)
print(
- "💡 Hint: Install Semgrep correctly or run with --external off.",
+ f"💡 Hint: {external_plan.semgrep.hint}",
file=sys.stderr,
)
return 1
- if run_zap:
+ if external_plan.zap.should_run:
print(f"🌐 OWASP ZAP baseline enabled: {zap_baseline_url}\n")
try:
findings.extend(_run_zap_baseline(zap_baseline_url))
except RuntimeError as exc:
- if auto_zap and not force_zap:
+ if external_plan.zap.auto_selected and not external_plan.zap.forced:
print(f"⚠️ Skipping ZAP auto integration: {exc}\n")
else:
print(f"❌ Error: {exc}", file=sys.stderr)
print(
- "💡 Hint: Install zap-baseline.py or run without --zap-baseline.",
+ f"💡 Hint: {external_plan.zap.hint}",
file=sys.stderr,
)
return 1
+ if findings_json:
+ try:
+ _write_findings_json(findings, Path(findings_json))
+ except RuntimeError as exc:
+ print(f"❌ Error: {exc}", file=sys.stderr)
+ print(
+ "💡 Hint: Check the output path and directory permissions.",
+ file=sys.stderr,
+ )
+ return 1
+
_print_scan_results(findings, files_scanned)
if files_scanned == 0:
return 1
return 1 if any(_is_deploy_blocking(f) for f in findings) else 0
+def _write_findings_json(findings, output_path: Path):
+ """Write normalized findings JSON for report builders and dashboards."""
+ normalized = normalize_findings(findings)
+ payload = {
+ "schema": "appguardrail.findings.v1",
+ "findings": list(normalized),
+ }
+ try:
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(
+ json.dumps(payload, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ except OSError as exc:
+ raise RuntimeError(f"Cannot write findings JSON: {output_path}") from exc
+ print(f"🧾 Findings JSON written: {output_path}")
+
+
def cmd_monitor(args):
"""Install a GitHub Actions workflow that runs AppGuardrail on changes."""
project_root = Path(".").resolve()
@@ -1383,15 +1522,162 @@ def cmd_monitor(args):
workflow_file.unlink()
workflow_file.write_text(MONITOR_WORKFLOW)
- print("\n✅ AppGuardrail monitor workflow installed!\n")
- print(f"Created/updated: {workflow_file.relative_to(project_root)}")
+ no_emoji = os.getenv("APPGUARDRAIL_NO_EMOJI", "0") == "1"
+ sparkles = "" if no_emoji else "✨ "
+ rocket = "" if no_emoji else "🚀 "
+ check = "" if no_emoji else "✅ "
+
+ print(f"\n{check}AppGuardrail monitor workflow installed!\n")
+ print(f"{sparkles}Created/updated: {workflow_file.relative_to(project_root)}")
print()
print(
- "This workflow runs `appguardrail scan .` on pull requests, pushes, and manual dispatches."
+ f"{rocket}This workflow runs `appguardrail scan .` on pull requests, pushes, and manual dispatches."
+ )
+ return 0
+
+
+def cmd_report(args):
+ """Generate markdown reports from normalized AppGuardrail findings JSON."""
+ report_type = getattr(args, "report_type", None)
+ if report_type not in supported_report_types():
+ print(f"❌ Error: Unsupported report type: {report_type}", file=sys.stderr)
+ print(
+ "💡 Hint: Supported report types are: "
+ + ", ".join(supported_report_types()),
+ file=sys.stderr,
+ )
+ return 1
+
+ try:
+ findings = _load_findings_json(Path(getattr(args, "findings")))
+ except (TypeError, RuntimeError) as exc:
+ print(f"❌ Error: {exc}", file=sys.stderr)
+ print(
+ "💡 Hint: Provide a JSON array or an object with a `findings` array.",
+ file=sys.stderr,
+ )
+ return 1
+
+ context = ReportContext(
+ app_name=getattr(args, "app_name", None) or "AppGuardrail scan target",
+ repository=getattr(args, "repository", None) or "n/a",
+ commit=getattr(args, "commit", None) or "n/a",
+ generated_at=getattr(args, "generated_at", None) or "",
+ scan_command=getattr(args, "scan_command", None) or "appguardrail scan .",
+ scope=getattr(args, "scope", None)
+ or "Application source, configuration, and security workflow evidence.",
+ client_name=getattr(args, "client_name", None) or "n/a",
+ reviewer=getattr(args, "reviewer", None) or "AppGuardrail",
+ engagement_type=getattr(args, "engagement_type", None) or "Pre-launch review",
+ based_on=getattr(args, "based_on", None) or "AppGuardrail findings JSON",
+ )
+ report = render_report(report_type, findings, context)
+
+ output_path = getattr(args, "out", None)
+ if output_path:
+ target = Path(output_path)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(report, encoding="utf-8")
+ print(f"✅ {REPORT_TYPE_LABELS[report_type]} written: {target}")
+ else:
+ print(report, end="")
+ return 0
+
+
+def cmd_org_bundle(args):
+ """Generate an organization buyer evidence bundle from GitHub state."""
+ owner = getattr(args, "owner", None) or "ContextualWisdomLab"
+ bundle_dir = Path(
+ getattr(args, "bundle_dir", None) or "appguardrail-buyer-evidence"
)
+ repos_json = getattr(args, "repos_json", None)
+ prs_json = getattr(args, "prs_json", None)
+ prs_repository = getattr(args, "prs_repository", None)
+ per_repo_pr_limit = getattr(args, "per_repo_pr_limit", 100)
+ active_repository_target = getattr(args, "active_repository_target", 20)
+
+ try:
+ repos = load_org_json(repos_json) if repos_json else gh_repo_list(owner)
+ collection_warnings: list[str] = []
+ if prs_json:
+ prs = load_org_json(prs_json)
+ else:
+ prs, collection_warnings = gh_pr_list(owner, repos, per_repo_pr_limit)
+ if prs_repository:
+ prs = annotate_missing_pr_repositories(prs, prs_repository)
+ generated_at, report, evidence_payload, inventory, pr_summary = (
+ render_org_evidence(
+ repos,
+ prs,
+ active_repository_target=active_repository_target,
+ generated_at=getattr(args, "generated_at", None),
+ )
+ )
+ manifest = write_bundle(
+ bundle_dir,
+ report=report,
+ evidence_payload=evidence_payload,
+ inventory=inventory,
+ pr_summary=pr_summary,
+ generated_at=generated_at,
+ owner=owner,
+ repos_source=repos_json,
+ prs_source=prs_json,
+ prs_repository_override=prs_repository,
+ per_repo_pr_limit=per_repo_pr_limit,
+ active_repository_target=active_repository_target,
+ collection_warnings=collection_warnings,
+ )
+ except OrgBundleError as exc:
+ print(f"❌ Error: {exc}", file=sys.stderr)
+ print(
+ "💡 Hint: Authenticate `gh` or provide --repos-json and --prs-json.",
+ file=sys.stderr,
+ )
+ return 1
+ except subprocess.CalledProcessError as exc:
+ print(
+ f"❌ Error: GitHub command failed: {gh_error_message(exc)}", file=sys.stderr
+ )
+ print(
+ "💡 Hint: Retry later or provide --repos-json and --prs-json.",
+ file=sys.stderr,
+ )
+ return 1
+
+ summary = manifest["summary"]
+ print(f"\n✅ Buyer evidence bundle written: {bundle_dir}\n")
+ print("Files:")
+ print(" - org-readiness.md")
+ print(" - buyer-evidence.json")
+ print(" - manifest.json")
+ print(" - README.md")
+ print()
+ print(f"Open PRs analyzed: {summary['open_pull_requests']}")
+ print(f"Buyer evidence status: {summary['buyer_evidence_status']}")
+ if manifest["collection_warnings"]:
+ print(f"Collection warnings: {len(manifest['collection_warnings'])}")
return 0
+def _load_findings_json(path: Path):
+ """Load a findings array from a JSON file or wrapped JSON object."""
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except OSError as exc:
+ raise RuntimeError(f"Cannot read findings JSON: {path}") from exc
+ except json.JSONDecodeError as exc:
+ raise RuntimeError(f"Findings JSON is invalid: {exc}") from exc
+
+ if isinstance(data, dict):
+ data = data.get("findings")
+ if not isinstance(data, list):
+ raise RuntimeError("Findings JSON must be an array or contain `findings`.")
+ if not all(isinstance(item, dict) for item in data):
+ raise RuntimeError("Every finding must be a JSON object.")
+ return data
+
+
def cmd_hook(args):
"""Install a pre-commit hook to block commits with vulnerabilities."""
project_root = Path(".").resolve()
@@ -1455,15 +1741,20 @@ def cmd_hook(args):
pre_commit_file.write_text(hook_content)
pre_commit_file.chmod(pre_commit_file.stat().st_mode | stat.S_IEXEC)
+ no_emoji = os.getenv("APPGUARDRAIL_NO_EMOJI", "0") == "1"
+ check = "" if no_emoji else "✅ "
+ rocket = "" if no_emoji else "🚀 "
+ compass = "" if no_emoji else "🧭 "
+
print(
- "\n✅ AppGuardrail pre-commit hook installed successfully at .git/hooks/pre-commit!\n"
+ f"\n{check}AppGuardrail pre-commit hook installed successfully at .git/hooks/pre-commit!\n"
)
hook_scan_command = f"appguardrail scan{scan_flags} ."
print(
- f"This will run '{hook_scan_command}' before every commit and block commits if vulnerabilities are found."
+ f"{rocket}This will run '{hook_scan_command}' before every commit and block commits if vulnerabilities are found."
)
if run_codegraph:
- print("CodeGraph mode is enabled for this hook.")
+ print(f"{compass}CodeGraph mode is enabled for this hook.")
return 0
@@ -1488,7 +1779,6 @@ def _get_applicable_rules(ext: str):
rule["severity"],
rule["message"],
rule["pattern"].finditer,
- rule["pattern"].search,
tuple(rule.get("include_paths") or ()),
tuple(rule.get("exclude_paths") or ()),
)
@@ -1664,7 +1954,14 @@ def _build_finding(
"""Build the normalized finding dictionary emitted by scan providers."""
context = _finding_context(file, snippet)
category = category or _finding_category(rule_id)
- return {
+ metadata = build_rule_metadata(
+ rule_id,
+ severity,
+ message,
+ category=category,
+ source=source,
+ )
+ finding = {
"rule_id": rule_id,
"severity": severity,
"message": message,
@@ -1678,14 +1975,13 @@ def _build_finding(
"fix_prompt": f"Fix {rule_id}: {message}",
"verification": f"Re-run `appguardrail scan` and verify {file}:{line} no longer reports this finding.",
}
+ finding.update(metadata.as_dict())
+ return finding
def _is_deploy_blocking(finding: dict) -> bool:
"""Return whether a finding should fail the deploy gate."""
- return (
- finding.get("severity") in DEPLOY_BLOCKING_SEVERITIES
- and finding.get("context", "app-code") not in NON_BLOCKING_CONTEXTS
- )
+ return core_is_deploy_blocking(finding)
_TRIVY_SEVERITY_MAP = {
@@ -1785,7 +2081,7 @@ def _run_trivy_fs(scan_path: Path):
)
try:
- process = subprocess.run(
+ process = subprocess.run( # noqa: S603 - Trivy path resolved with shutil.which
[
trivy,
"fs",
@@ -1860,7 +2156,7 @@ def _run_bandit_scan(scan_path: Path):
command.append(str(scan_path))
try:
- process = subprocess.run(
+ process = subprocess.run( # noqa: S603 - Bandit path resolved with shutil.which
command,
shell=False,
capture_output=True,
@@ -1873,7 +2169,9 @@ def _run_bandit_scan(scan_path: Path):
if process.returncode not in {0, 1}:
detail = (process.stderr or process.stdout).strip().splitlines()
- raise RuntimeError("Bandit scan failed" + (f": {detail[-1]}" if detail else "."))
+ raise RuntimeError(
+ "Bandit scan failed" + (f": {detail[-1]}" if detail else ".")
+ )
try:
report = json.loads(process.stdout or "{}")
@@ -1919,7 +2217,7 @@ def _run_ruff_security_scan(scan_path: Path):
raise RuntimeError("ruff executable not found.")
try:
- process = subprocess.run(
+ process = subprocess.run( # noqa: S603 - Ruff path resolved with shutil.which
[
ruff,
"check",
@@ -1972,9 +2270,11 @@ def _semgrep_findings(report: dict, base_path: Path):
for item in report.get("results") or []:
extra = item.get("extra") or {}
start = item.get("start") or {}
+ # fmt: off
path = _sanitize_terminal_output(
_trivy_target(item.get("path", ""), base_path)
)
+ # fmt: on
check_id = item.get("check_id") or "semgrep"
findings.append(
_build_finding(
@@ -1998,20 +2298,22 @@ def _run_semgrep_scan(scan_path: Path, config: str = "auto"):
config = config or "auto"
try:
- process = subprocess.run(
- [
- semgrep,
- "scan",
- "--config",
- config,
- "--json",
- str(scan_path),
- ],
- shell=False,
- capture_output=True,
- text=True,
- check=False,
- timeout=600,
+ process = (
+ subprocess.run( # noqa: S603 - Semgrep path resolved with shutil.which
+ [
+ semgrep,
+ "scan",
+ "--config",
+ config,
+ "--json",
+ str(scan_path),
+ ],
+ shell=False,
+ capture_output=True,
+ text=True,
+ check=False,
+ timeout=600,
+ )
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError("Semgrep scan timed out.") from exc
@@ -2078,13 +2380,15 @@ def _run_zap_baseline(target_url: str):
with tempfile.TemporaryDirectory() as tmpdir:
report_path = Path(tmpdir) / "zap-baseline.json"
try:
- process = subprocess.run(
- [zap, "-t", target_url, "-J", str(report_path), "-I"],
- shell=False,
- capture_output=True,
- text=True,
- check=False,
- timeout=900,
+ process = (
+ subprocess.run( # noqa: S603 - ZAP path resolved with shutil.which
+ [zap, "-t", target_url, "-J", str(report_path), "-I"],
+ shell=False,
+ capture_output=True,
+ text=True,
+ check=False,
+ timeout=900,
+ )
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError("ZAP baseline scan timed out.") from exc
@@ -2125,7 +2429,7 @@ def _run_codegraph_command(command, cwd: Path, action: str):
raise RuntimeError(f"Unsupported CodeGraph {action} command.")
try:
- process = subprocess.run(
+ process = subprocess.run( # noqa: S603 - command is checked against allowlist
command,
cwd=cwd,
capture_output=True,
@@ -2216,7 +2520,6 @@ def _scan_file(file_path: Path, base_path: Path):
severity,
message,
finditer,
- search_method,
include_paths,
exclude_paths,
) in applicable_rules:
@@ -2233,10 +2536,6 @@ def _scan_file(file_path: Path, base_path: Path):
rel_path_for_filters, include_paths, exclude_paths
):
continue
- # ⚡ Bolt: Fast path rejection using pre-bound search method
- if not search_method(content):
- continue
-
# ⚡ Bolt: Progressive line counting for O(N) instead of O(N*M)
# finditer yields matches in order, allowing us to scan for newlines
# incrementally from the last known position rather than starting from 0.
@@ -2476,6 +2775,11 @@ def main():
default=None,
help="Run OWASP ZAP baseline scan against this http(s) URL",
)
+ scan_parser.add_argument(
+ "--findings-json",
+ default=None,
+ help="Write normalized findings JSON for report builders and dashboards",
+ )
scan_parser.add_argument(
"--codegraph",
action="store_true",
@@ -2498,6 +2802,105 @@ def main():
)
review_parser.add_argument("--payments", help="Payment provider (e.g. stripe)")
+ # report
+ report_parser = subparsers.add_parser(
+ "report", help="Generate product and diligence reports from findings JSON"
+ )
+ report_subparsers = report_parser.add_subparsers(dest="report_type")
+
+ def add_report_arguments(parser):
+ parser.add_argument(
+ "--findings",
+ required=True,
+ help="Path to findings JSON array or object with a findings array",
+ )
+ parser.add_argument(
+ "--out",
+ default=None,
+ help="Write report to this markdown path instead of stdout",
+ )
+ parser.add_argument("--app-name", default=None, help="Application name")
+ parser.add_argument("--repository", default=None, help="Repository name")
+ parser.add_argument("--commit", default=None, help="Commit SHA or version")
+ parser.add_argument(
+ "--generated-at", default=None, help="Report timestamp in ISO-8601 form"
+ )
+ parser.add_argument(
+ "--scan-command", default=None, help="Scan command used to produce findings"
+ )
+ parser.add_argument("--scope", default=None, help="Report scope summary")
+ parser.add_argument("--client-name", default=None, help="Agency client name")
+ parser.add_argument("--reviewer", default=None, help="Reviewer or agency name")
+ parser.add_argument(
+ "--engagement-type",
+ default=None,
+ help="Agency engagement type, such as pre-launch review",
+ )
+ parser.add_argument(
+ "--based-on",
+ default=None,
+ help="Review ID, issue, PR, or scan artifact this report is based on",
+ )
+
+ report_help = {
+ "buyer-diligence": "Generate a buyer diligence markdown report",
+ "founder-friendly": "Generate a plain-language founder report",
+ "agency": "Generate an agency/client security review report",
+ "fix-pack": "Generate AI-ready remediation prompts and verification steps",
+ }
+ for report_type in supported_report_types():
+ add_report_arguments(
+ report_subparsers.add_parser(report_type, help=report_help[report_type])
+ )
+
+ # org-bundle
+ org_bundle_parser = subparsers.add_parser(
+ "org-bundle",
+ help="Generate an organization buyer evidence bundle",
+ )
+ org_bundle_parser.add_argument(
+ "--owner",
+ default="ContextualWisdomLab",
+ help="GitHub organization owner (default: ContextualWisdomLab)",
+ )
+ org_bundle_parser.add_argument(
+ "--bundle-dir",
+ default="appguardrail-buyer-evidence",
+ help="Directory to write bundle artifacts",
+ )
+ org_bundle_parser.add_argument(
+ "--repos-json",
+ default=None,
+ help="Use a gh repo list JSON file instead of live GitHub repository lookup",
+ )
+ org_bundle_parser.add_argument(
+ "--prs-json",
+ default=None,
+ help="Use a pull request JSON file instead of live GitHub PR lookup",
+ )
+ org_bundle_parser.add_argument(
+ "--prs-repository",
+ default=None,
+ help="Repository name to attach to PR rows missing repository metadata",
+ )
+ org_bundle_parser.add_argument(
+ "--per-repo-pr-limit",
+ type=int,
+ default=100,
+ help="Maximum open PRs to inspect per non-fork repository",
+ )
+ org_bundle_parser.add_argument(
+ "--active-repository-target",
+ type=int,
+ default=20,
+ help="Non-fork repository target used by buyer evidence KPIs",
+ )
+ org_bundle_parser.add_argument(
+ "--generated-at",
+ default=None,
+ help="Override bundle timestamp in ISO-8601 form",
+ )
+
# hook
hook_parser = subparsers.add_parser(
"hook", help="Install a pre-commit hook to block commits with vulnerabilities"
@@ -2518,6 +2921,10 @@ def main():
sys.exit(cmd_monitor(args))
elif args.command == "review":
cmd_review(args)
+ elif args.command == "report":
+ sys.exit(cmd_report(args))
+ elif args.command == "org-bundle":
+ sys.exit(cmd_org_bundle(args))
elif args.command == "hook":
sys.exit(cmd_hook(args))
else:
diff --git a/scripts/ci/collect_org_security_failures.py b/scripts/ci/collect_org_security_failures.py
index b4fd334..c966b56 100644
--- a/scripts/ci/collect_org_security_failures.py
+++ b/scripts/ci/collect_org_security_failures.py
@@ -7,47 +7,34 @@
import datetime as dt
import json
import os
-import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
+from appguardrail_core.issueops import (
+ DEFAULT_MAX_LOG_CHARS,
+ DEFAULT_MAX_LOG_LINES,
+ compress_log,
+ is_failure,
+ is_security_name,
+ issue_body,
+ issue_comment,
+ parse_marker,
+ parse_run_url,
+ replace_marker,
+ sanitize_label_value,
+ seen_key,
+ title,
+)
+
API = "https://api.github.com"
UA = "appguardrail-org-security-failure-collector"
-FAILURES = {"failure", "cancelled", "timed_out", "action_required"}
-SECURITY_TERMS = ("strix", "opencode", "appguardrail", "trivy", "codeql", "security process")
ISSUE_LABEL = "org-security-failure"
SECURITY_LABEL = "security-ci"
-MARKER_PREFIX = ""
DEFAULT_LOOKBACK_HOURS = 48
-DEFAULT_MAX_LOG_CHARS = 30_000
-DEFAULT_MAX_LOG_LINES = 200
-
-ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
-TS_RE = re.compile(r"^\ufeff?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s*")
-SECRET_RE = [
- re.compile(r"(?i)(authorization:\s*(?:bearer|token)\s+)[^\s]+"),
- re.compile(r"(?i)\b((?:api[_-]?key|token|secret|password|private[_-]?key)\s*[:=]\s*)['\"]?[^'\"\s]+"),
- re.compile(r"\b(?:gh[opsu]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]+|sk-[A-Za-z0-9]{20,})\b"),
- re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"),
-]
-PRIMARY_LOG_RE = [
- re.compile(p, re.IGNORECASE)
- for p in (
- r"^\s*::error::",
- r"traceback",
- r"vuln-",
- r"\bcritical\b",
- r"\bhigh\b",
- r"ratelimiterror",
- r"unable to map strix findings",
- r"\btimeout\b|\btimed out\b",
- )
-]
-FALLBACK_LOG_RE = [re.compile(r"\bfailed\b|\berror\b|\bfatal\b", re.IGNORECASE)]
+BLOCKED_LOG_HOSTS = {"localhost", "127.0.0.1", "169.254.169.254", "0.0.0.0", "::1"}
class NoRedirect(urllib.request.HTTPRedirectHandler):
@@ -55,6 +42,29 @@ def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
+class SecureRedirectHandler(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ _validate_log_download_url(newurl)
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
+
+
+def _redacted_url(parsed: urllib.parse.ParseResult) -> str:
+ return f"{parsed.scheme}://{parsed.hostname or ''}{parsed.path}"
+
+
+def _validate_log_download_url(url: str) -> urllib.parse.ParseResult:
+ parsed = urllib.parse.urlparse(url)
+ if parsed.scheme not in {"http", "https"}:
+ raise urllib.error.URLError(
+ f"Invalid or dangerous URL scheme in location: {_redacted_url(parsed)}"
+ )
+ if parsed.hostname in BLOCKED_LOG_HOSTS:
+ raise urllib.error.URLError(
+ f"Access to internal address blocked: {_redacted_url(parsed)}"
+ )
+ return parsed
+
+
class GitHub:
def __init__(self, token: str, api: str = API):
self.token = token
@@ -63,7 +73,7 @@ def __init__(self, token: str, api: str = API):
def request(self, method: str, path: str, data: dict[str, Any] | None = None, params: dict[str, Any] | None = None) -> Any:
query = f"?{urllib.parse.urlencode(params)}" if params else ""
body = json.dumps(data).encode() if data is not None else None
- req = urllib.request.Request(
+ req = urllib.request.Request( # noqa: S310 - GitHub API URL
f"{self.api}{path}{query}",
data=body,
method=method,
@@ -76,7 +86,7 @@ def request(self, method: str, path: str, data: dict[str, Any] | None = None, pa
},
)
try:
- with urllib.request.urlopen(req, timeout=30) as res:
+ with urllib.request.urlopen(req, timeout=30) as res: # noqa: S310 - GitHub API URL
payload = res.read()
content_type = res.headers.get("content-type", "")
except urllib.error.HTTPError as exc:
@@ -107,7 +117,7 @@ def pages(self, path: str, params: dict[str, Any] | None = None) -> list[Any]:
def job_log(self, repo: str, job_id: int) -> str:
path = f"/repos/{repo}/actions/jobs/{job_id}/logs"
- req = urllib.request.Request(
+ req = urllib.request.Request( # noqa: S310 - GitHub API URL
f"{self.api}{path}",
method="GET",
headers={
@@ -127,11 +137,18 @@ def job_log(self, repo: str, job_id: int) -> str:
detail = exc.read().decode("utf-8", errors="replace")
return f"Could not fetch job log: GitHub API GET {path} failed: {exc.code} {detail}"
try:
- with urllib.request.urlopen(urllib.request.Request(location, headers={"User-Agent": UA}), timeout=30) as res:
+ _validate_log_download_url(location)
+ download_req = urllib.request.Request( # noqa: S310 - GitHub log redirect URL
+ location, headers={"User-Agent": UA}
+ )
+ opener = urllib.request.build_opener(SecureRedirectHandler)
+ with opener.open(download_req, timeout=30) as res:
return res.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
return f"Could not fetch job log: GitHub download failed: {exc.code} {detail}"
+ except urllib.error.URLError as exc:
+ return f"Could not fetch job log: {exc.reason}"
def utc_now() -> dt.datetime:
@@ -143,148 +160,6 @@ def parse_time(value: str) -> dt.datetime:
return parsed if parsed.tzinfo else parsed.replace(tzinfo=dt.timezone.utc)
-def is_failure(conclusion: str | None) -> bool:
- return (conclusion or "").lower() in FAILURES
-
-
-def is_security_name(*names: str | None) -> bool:
- 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]:
- 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:
- value = re.sub(r"[^A-Za-z0-9._:-]+", "-", value.strip()).strip("-")
- return value[:45] or "unknown"
-
-
-def redact(log: str) -> str:
- 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:
- text = regex.sub(lambda m: f"{m.group(1)}[REDACTED]" if m.lastindex else "[REDACTED]", text)
- return text
-
-
-def log_ranges(lines: list[str], patterns: list[re.Pattern[str]]) -> list[tuple[int, int]]:
- return [
- (max(0, i - 2), min(len(lines), i + 9))
- for i, line in enumerate(lines)
- if any(pattern.search(line) for pattern in patterns)
- ]
-
-
-def compress_log(log: str, max_lines: int = DEFAULT_MAX_LOG_LINES, max_chars: int = DEFAULT_MAX_LOG_CHARS) -> str:
- lines = redact(log).splitlines()
- if not lines:
- return "(no job log returned)"
- ranges = log_ranges(lines, PRIMARY_LOG_RE) or log_ranges(lines, FALLBACK_LOG_RE)
- if not ranges:
- selected = lines[-max_lines:]
- else:
- merged: list[tuple[int, int]] = []
- for start, end in sorted(ranges):
- if merged and start <= merged[-1][1]:
- merged[-1] = (merged[-1][0], max(merged[-1][1], end))
- else:
- merged.append((start, end))
- chosen: list[tuple[int, int]] = []
- count = 0
- for start, end in reversed(merged):
- chosen.append((start, end))
- count += end - start + (1 if len(chosen) > 1 else 0)
- if count >= max_lines:
- break
- selected = []
- for start, end in sorted(chosen):
- if selected:
- selected.append("...")
- selected.extend(lines[start:end])
- if len(selected) >= max_lines:
- break
- selected = selected[:max_lines]
- snippet = "\n".join(selected)
- if len(snippet) > max_chars:
- snippet = snippet[:max_chars].rstrip() + "\n...[truncated]"
- if len(lines) > len(selected):
- snippet += "\n...[compressed]"
- return snippet
-
-
-def seen_key(finding: dict[str, Any]) -> str:
- return f"{finding['run_id']}:{finding['job_id']}"
-
-
-def marker(repo: str, workflow: str, seen: set[str]) -> str:
- 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]:
- body = body or ""
- start = body.find(MARKER_PREFIX)
- end = body.find(MARKER_SUFFIX, start + len(MARKER_PREFIX))
- if start == -1 or end == -1:
- return {"seen": []}
- try:
- return json.loads(body[start + len(MARKER_PREFIX):end].strip())
- except json.JSONDecodeError:
- return {"seen": []}
-
-
-def replace_marker(body: str | None, repo: str, workflow: str, seen: set[str]) -> str:
- body = body or ""
- new_marker = marker(repo, workflow, seen)
- start = body.find(MARKER_PREFIX)
- end = body.find(MARKER_SUFFIX, start + len(MARKER_PREFIX))
- if start == -1 or end == -1:
- return f"{new_marker}\n\n{body}".strip()
- return f"{body[:start]}{new_marker}{body[end + len(MARKER_SUFFIX):]}".strip()
-
-
-def title(finding: dict[str, Any]) -> str:
- return f"[security-failure] {finding['repo']}: {finding['workflow']}"
-
-
-def summary(finding: dict[str, Any]) -> str:
- prs = ", ".join(f"#{number}" for number in finding["pr_numbers"]) or "n/a"
- rows = [
- ("Repository", f"`{finding['repo']}`"),
- ("Workflow", f"`{finding['workflow']}`"),
- ("Job", f"`{finding['job_name']}`"),
- ("Conclusion", f"`{finding['conclusion']}`"),
- ("Branch", f"`{finding['branch']}`"),
- ("Head SHA", f"`{finding['head_sha']}`"),
- ("Event", f"`{finding['event']}`"),
- ("PRs", prs),
- ("Run", finding["run_url"]),
- ("Job", finding["job_url"]),
- ]
- return "\n".join(f"- {key}: {value}" for key, value in rows)
-
-
-def issue_body(finding: dict[str, Any], seen: set[str]) -> str:
- owner = finding["repo"].split("/", 1)[0]
- return "\n\n".join(
- [
- marker(finding["repo"], finding["workflow"], seen),
- f"Automated collection of security workflow failures across {owner}.",
- summary(finding),
- f"```text\n{finding['snippet']}\n```",
- ]
- )
-
-
-def issue_comment(finding: dict[str, Any]) -> str:
- return "\n\n".join(["New security workflow failure detected.", summary(finding), f"```text\n{finding['snippet']}\n```"])
-
-
def build_finding(client: GitHub, repo: str, run: dict[str, Any], job: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]:
job_id = int(job["id"])
return {
diff --git a/scripts/ci/render_org_readiness_report.py b/scripts/ci/render_org_readiness_report.py
new file mode 100644
index 0000000..e312339
--- /dev/null
+++ b/scripts/ci/render_org_readiness_report.py
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+"""Render an AppGuardrail organization readiness report from GitHub JSON."""
+
+from __future__ import annotations
+
+import argparse
+import subprocess
+import sys
+from pathlib import Path
+
+if __package__ in (None, ""):
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+
+from appguardrail_core.org_bundle import (
+ OrgBundleError,
+ annotate_missing_pr_repositories as _annotate_missing_pr_repositories,
+ gh_error_message as _gh_error_message,
+ gh_pr_list as _gh_pr_list,
+ gh_repo_list as _gh_repo_list,
+ load_json as _load_json,
+ render_org_evidence,
+ write_bundle as _write_bundle,
+ write_json as _write_json,
+)
+
+
+def parse_args(argv: list[str]) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--owner", default="ContextualWisdomLab")
+ parser.add_argument("--repos-json", help="Path to gh repo list JSON output.")
+ parser.add_argument("--prs-json", help="Path to gh search prs JSON output.")
+ parser.add_argument("--prs-repository", help="Repository name to attach to PR JSON rows that do not include repository metadata.")
+ parser.add_argument("--out", help="Write markdown report to this path.")
+ parser.add_argument("--json-out", help="Write buyer evidence JSON payload to this path.")
+ parser.add_argument("--bundle-dir", help="Write a buyer evidence bundle directory with Markdown, JSON, manifest, and README.")
+ parser.add_argument("--generated-at", help="Override generated timestamp, primarily for reproducible evidence snapshots.")
+ parser.add_argument("--per-repo-pr-limit", type=int, default=100)
+ parser.add_argument("--active-repository-target", type=int, default=20)
+ return parser.parse_args(argv)
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = parse_args(argv or sys.argv[1:])
+ try:
+ repos = _load_json(args.repos_json) if args.repos_json else _gh_repo_list(args.owner)
+ collection_warnings: list[str] = []
+ if args.prs_json:
+ prs = _load_json(args.prs_json)
+ else:
+ prs, collection_warnings = _gh_pr_list(args.owner, repos, args.per_repo_pr_limit)
+ if args.prs_repository:
+ prs = _annotate_missing_pr_repositories(prs, args.prs_repository)
+ generated_at, report, evidence_payload, inventory, pr_summary = render_org_evidence(
+ repos,
+ prs,
+ active_repository_target=args.active_repository_target,
+ generated_at=args.generated_at,
+ )
+ except OrgBundleError as exc:
+ raise SystemExit(str(exc)) from exc
+ except subprocess.CalledProcessError as exc:
+ raise SystemExit(f"GitHub command failed: {_gh_error_message(exc)}") from exc
+
+ if args.json_out:
+ _write_json(Path(args.json_out), evidence_payload)
+ if args.bundle_dir:
+ _write_bundle(
+ Path(args.bundle_dir),
+ report=report,
+ evidence_payload=evidence_payload,
+ inventory=inventory,
+ pr_summary=pr_summary,
+ generated_at=generated_at,
+ owner=args.owner,
+ repos_source=args.repos_json,
+ prs_source=args.prs_json,
+ prs_repository_override=args.prs_repository,
+ per_repo_pr_limit=args.per_repo_pr_limit,
+ active_repository_target=args.active_repository_target,
+ collection_warnings=collection_warnings,
+ )
+ if args.out:
+ Path(args.out).write_text(report)
+ else:
+ print(report, end="")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_appguardrail.py b/tests/test_appguardrail.py
index ad58dbf..3d1a633 100644
--- a/tests/test_appguardrail.py
+++ b/tests/test_appguardrail.py
@@ -10,6 +10,7 @@
SCAN_RULES,
_bandit_findings,
_collect_files,
+ _build_finding,
_detect_scan_languages,
_load_packaged_regex_rules,
_path_allowed_by_rule,
@@ -26,6 +27,8 @@
_semgrep_findings,
cmd_init,
cmd_monitor,
+ cmd_org_bundle,
+ cmd_report,
cmd_scan,
)
@@ -77,6 +80,35 @@ class MonitorArgs:
pass
+class ReportArgs:
+ def __init__(self, findings, out=None, report_type="buyer-diligence"):
+ self.report_type = report_type
+ self.findings = str(findings)
+ self.out = str(out) if out else None
+ self.app_name = "Demo SaaS"
+ self.repository = "ContextualWisdomLab/demo"
+ self.commit = "abc123"
+ self.generated_at = "2026-07-02T00:00:00Z"
+ self.scan_command = "appguardrail scan ."
+ self.scope = "Demo app source and workflow evidence."
+ self.client_name = "Demo Client"
+ self.reviewer = "Demo Agency"
+ self.engagement_type = "Pre-launch review"
+ self.based_on = "review-123"
+
+
+class OrgBundleArgs:
+ def __init__(self, bundle_dir, repos_json, prs_json):
+ self.owner = "ContextualWisdomLab"
+ self.bundle_dir = str(bundle_dir)
+ self.repos_json = str(repos_json)
+ self.prs_json = str(prs_json)
+ self.prs_repository = "ContextualWisdomLab/appguardrail"
+ self.per_repo_pr_limit = 30
+ self.active_repository_target = 2
+ self.generated_at = "2026-07-03T00:00:00Z"
+
+
def _create_symlink(target, link, target_is_directory=False):
try:
link.symlink_to(target, target_is_directory=target_is_directory)
@@ -251,6 +283,53 @@ def test_scan_file_detects_strix_derived_patterns(tmp_path):
),
"ids": {"python-absolute-path-traversal-check-missing"},
},
+ "snowflake.py": {
+ "content": (
+ "parsed = urlparse(authenticator)\n"
+ "if parsed.hostname.endswith('.okta.com'):\n"
+ " return authenticator\n"
+ ),
+ "ids": {"python-okta-host-endswith-ssrf"},
+ },
+ "slow_process.py": {
+ "content": "subprocess.run(['ffmpeg', '-i', source_path], check=True)\n",
+ "ids": {"python-subprocess-missing-timeout"},
+ },
+ "extract-frames.sh": {
+ "content": 'awk "BEGIN { print $NUM_FRAMES / $DURATION }"\n',
+ "ids": {"shell-awk-variable-injection"},
+ },
+ "auth-flow.ts": {
+ "content": "exec(authUrl)\n",
+ "ids": {"node-exec-url-command-injection"},
+ },
+ "export.ts": {
+ "content": "writeFileSync(output, contents)\n",
+ "ids": {"node-unvalidated-output-path-write"},
+ },
+ "audio_separator.py": {
+ "content": "audio_file = Path(input_path).expanduser()\n",
+ "ids": {"python-expanduser-user-path-traversal"},
+ },
+ "strix.yml": {
+ "content": (
+ "env:\n"
+ " LLM_API_KEY: ${{ secrets.LLM_API_KEY }}\n"
+ " REVIEW_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
+ ),
+ "ids": {
+ "github-actions-secret-env-passthrough",
+ "github-actions-secrets-github-token",
+ },
+ },
+ "backup.sh": {
+ "content": 'docker run -e DB_PASS="$DB_PASS" postgres:16\n',
+ "ids": {"docker-cli-secret-env-leak"},
+ },
+ "external.html": {
+ "content": 'external\n',
+ "ids": {"html-target-blank-without-noopener"},
+ },
}
for name, sample in samples.items():
@@ -575,6 +654,58 @@ def fake_available(name, version_args=("--version",)):
semgrep.assert_called_once_with(tmp_path.resolve(), "auto")
+def test_cmd_scan_prints_beginner_profile_without_user_flags(tmp_path, capsys):
+ (tmp_path / "pyproject.toml").write_text('[project]\ndependencies = ["fastapi"]\n')
+ (tmp_path / "app.py").write_text("from fastapi import FastAPI\n")
+
+ with patch("scanner.cli.appguardrail.SCAN_RULES", []):
+ assert cmd_scan(ScanArgs(tmp_path)) == 0
+
+ out = capsys.readouterr().out
+ assert "Detected language axes: python" in out
+ assert "Beginner profile: Python web application" in out
+ assert "Optional external engines: bandit, ruff, semgrep, trivy" in out
+
+
+def test_cmd_scan_auto_external_explains_missing_optional_engines(tmp_path, capsys):
+ (tmp_path / "pyproject.toml").write_text('[project]\ndependencies = ["fastapi"]\n')
+ (tmp_path / "app.py").write_text("from fastapi import FastAPI\n")
+
+ args = ScanArgs(tmp_path)
+ args.external = "auto"
+
+ with patch("scanner.cli.appguardrail.SCAN_RULES", []), patch(
+ "scanner.cli.appguardrail._external_tool_available", return_value=None
+ ):
+ assert cmd_scan(args) == 0
+
+ out = capsys.readouterr().out
+ assert "External auto mode:" in out
+ assert "Skipped Bandit: executable not found or not runnable" in out
+ assert "Skipped Ruff security rules: executable not found or not runnable" in out
+ assert "Skipped Semgrep: executable not found or not runnable" in out
+
+
+@patch("scanner.cli.appguardrail.SCAN_RULES", MOCK_RULES)
+def test_cmd_scan_writes_normalized_findings_json(tmp_path, capsys):
+ test_file = tmp_path / "unsafe.ts"
+ test_file.write_text("const key = MOCK_SECRET_KEY;\n")
+ findings_json = tmp_path / "reports" / "findings.json"
+
+ args = ScanArgs(tmp_path)
+ args.findings_json = str(findings_json)
+
+ assert cmd_scan(args) == 1
+
+ payload = json.loads(findings_json.read_text())
+ assert payload["schema"] == "appguardrail.findings.v1"
+ assert payload["findings"][0]["rule_id"] == "mock-secret"
+ assert payload["findings"][0]["severity"] == "CRITICAL"
+ assert payload["findings"][0]["context"] == "app-code"
+ assert "managed secret storage" in payload["findings"][0]["remediation"]
+ assert "Findings JSON written" in capsys.readouterr().out
+
+
def test_cmd_scan_streams_collected_files_while_detecting_languages(tmp_path):
files = [tmp_path / "first.py", tmp_path / "second.py"]
for file_path in files:
@@ -677,6 +808,26 @@ def test_run_trivy_fs_maps_json_findings(tmp_path):
assert "SHOULD_NOT_PRINT" not in findings[2]["snippet"]
+def test_build_finding_adds_public_security_metadata():
+ finding = _build_finding(
+ "appguardrail-rule",
+ "python-requests-verify-false",
+ "HIGH",
+ (
+ "HTTP client disables TLS certificate verification. "
+ "[CWE-295 - Improper Certificate Validation]"
+ ),
+ "client.py",
+ 3,
+ "requests.get(url, verify=False)",
+ )
+
+ assert finding["cwe"] == ("CWE-295 - Improper Certificate Validation",)
+ assert finding["owasp"] == ("OWASP A05:2021 - Security Misconfiguration",)
+ assert finding["samm_practice"] == "Operations / Environment Management"
+ assert finding["remediation"]
+
+
def test_run_trivy_fs_passes_scan_path_as_literal_argument(tmp_path):
scan_path = tmp_path / "literal;touch INJECTED"
scan_path.mkdir()
@@ -693,6 +844,7 @@ def test_run_trivy_fs_passes_scan_path_as_literal_argument(tmp_path):
command = run.call_args.args[0]
assert command[-1] == str(scan_path)
assert run.call_args.kwargs["shell"] is False
+ assert run.call_args.kwargs["timeout"] == 300
def test_run_trivy_fs_requires_trivy(tmp_path):
@@ -931,6 +1083,8 @@ def test_run_codegraph_index_initializes_when_missing(tmp_path):
assert run.call_args_list[0].args[0] == ["/usr/bin/codegraph", "init", "-i"]
assert run.call_args_list[1].args[0] == ["/usr/bin/codegraph", "status"]
+ assert run.call_args_list[0].kwargs["timeout"] == 120
+ assert run.call_args_list[1].kwargs["timeout"] == 120
def test_run_codegraph_index_syncs_existing_index(tmp_path):
@@ -953,6 +1107,8 @@ def test_run_codegraph_index_syncs_existing_index(tmp_path):
assert run.call_args_list[0].args[0] == ["/usr/bin/codegraph", "sync"]
assert run.call_args_list[1].args[0] == ["/usr/bin/codegraph", "status"]
+ assert run.call_args_list[0].kwargs["timeout"] == 120
+ assert run.call_args_list[1].kwargs["timeout"] == 120
def test_run_codegraph_index_requires_codegraph(tmp_path):
@@ -1331,6 +1487,130 @@ def test_cmd_monitor_path_traversal(tmp_path, monkeypatch, capsys):
assert "escapes the project root" in capsys.readouterr().err
+def test_cmd_report_buyer_diligence_writes_markdown(tmp_path, capsys):
+ findings_file = tmp_path / "findings.json"
+ findings_file.write_text(
+ json.dumps(
+ [
+ {
+ "rule_id": "python-requests-verify-false",
+ "severity": "HIGH",
+ "message": "HTTP client disables TLS certificate verification.",
+ "file": "client.py",
+ "line": 7,
+ "snippet": "requests.get(url, verify=False)",
+ "references": ("CWE-295 - Improper Certificate Validation",),
+ "remediation": "Keep certificate verification enabled.",
+ }
+ ]
+ )
+ )
+ out_file = tmp_path / "reports" / "buyer-diligence.md"
+
+ assert cmd_report(ReportArgs(findings_file, out_file)) == 0
+
+ report = out_file.read_text()
+ assert "# AppGuardrail Buyer Diligence Report" in report
+ assert "**App:** Demo SaaS" in report
+ assert "python-requests-verify-false" in report
+ assert "Buyer diligence report written" in capsys.readouterr().out
+
+
+def test_cmd_report_fix_pack_writes_markdown(tmp_path, capsys):
+ findings_file = tmp_path / "findings.json"
+ findings_file.write_text(
+ json.dumps(
+ {
+ "findings": [
+ {
+ "rule_id": "python-requests-verify-false",
+ "severity": "HIGH",
+ "message": "HTTP client disables TLS certificate verification.",
+ "file": "client.py",
+ "line": 7,
+ "snippet": "requests.get(url, verify=False)",
+ "remediation": "Keep certificate verification enabled.",
+ }
+ ]
+ }
+ )
+ )
+ out_file = tmp_path / "reports" / "fix-pack.md"
+
+ assert cmd_report(ReportArgs(findings_file, out_file, "fix-pack")) == 0
+
+ report = out_file.read_text()
+ assert "# AppGuardrail Fix Pack" in report
+ assert "FIX-001" in report
+ assert "python-requests-verify-false" in report
+ assert "Fix pack written" in capsys.readouterr().out
+
+
+def test_cmd_report_rejects_invalid_findings_shape(tmp_path, capsys):
+ findings_file = tmp_path / "findings.json"
+ findings_file.write_text(json.dumps({"findings": "not-a-list"}))
+
+ assert cmd_report(ReportArgs(findings_file)) == 1
+
+ err = capsys.readouterr().err
+ assert "Findings JSON must be an array" in err
+ assert "Provide a JSON array" in err
+
+
+def test_cmd_org_bundle_writes_beginner_buyer_bundle(tmp_path, capsys):
+ repos_json = tmp_path / "repos.json"
+ prs_json = tmp_path / "prs.json"
+ bundle_dir = tmp_path / "bundle"
+ repos_json.write_text(
+ json.dumps(
+ [
+ {
+ "name": "appguardrail",
+ "isFork": False,
+ "isPrivate": False,
+ "primaryLanguage": {"name": "Python"},
+ "defaultBranchRef": {"name": "develop"},
+ },
+ {
+ "name": "waf-ids-ai-soc",
+ "isFork": False,
+ "isPrivate": True,
+ "primaryLanguage": {"name": "Rust"},
+ "defaultBranchRef": {"name": "main"},
+ },
+ ]
+ )
+ )
+ prs_json.write_text(
+ json.dumps(
+ [
+ {
+ "number": 157,
+ "title": "Resolve source conflict",
+ "isDraft": False,
+ "mergeable": "CONFLICTING",
+ "mergeStateStatus": "DIRTY",
+ "reviewDecision": "",
+ "statusCheckRollup": [{"status": "QUEUED"}],
+ }
+ ]
+ )
+ )
+
+ assert cmd_org_bundle(OrgBundleArgs(bundle_dir, repos_json, prs_json)) == 0
+
+ manifest = json.loads((bundle_dir / "manifest.json").read_text())
+ assert (bundle_dir / "org-readiness.md").exists()
+ assert (bundle_dir / "buyer-evidence.json").exists()
+ assert (bundle_dir / "README.md").exists()
+ assert manifest["source"]["repositories"]["kind"] == "file"
+ assert manifest["summary"]["open_pull_requests"] == 1
+ assert manifest["summary"]["action_bucket_counts"]["source-work"] == 1
+ out = capsys.readouterr().out
+ assert "Buyer evidence bundle written" in out
+ assert "Open PRs analyzed: 1" in out
+
+
def test_cmd_init_unknown_tool(tmp_path, monkeypatch, capsys):
monkeypatch.chdir(tmp_path)
diff --git a/tests/test_appguardrail_coverage.py b/tests/test_appguardrail_coverage.py
index 2b5960e..a53fc8a 100644
--- a/tests/test_appguardrail_coverage.py
+++ b/tests/test_appguardrail_coverage.py
@@ -1,5 +1,3 @@
-import os
-from pathlib import Path
from unittest.mock import patch
import pytest
@@ -229,10 +227,6 @@ def test_cmd_hook_remove_symlink(tmp_path, monkeypatch):
def test_collect_files_oserror_on_scandir(tmp_path):
- import os
-
- original_scandir = os.scandir
-
def mock_scandir(path):
raise PermissionError("Mock permission error")
@@ -242,10 +236,6 @@ def mock_scandir(path):
def test_collect_files_oserror_on_entry(tmp_path):
- import os
-
- original_scandir = os.scandir
-
class MockEntry:
def __init__(self, is_dir_val, is_file_val, is_symlink_val):
self._is_dir = is_dir_val
@@ -280,8 +270,6 @@ def __exit__(self, *args):
def test_scan_file_lstat_oserror(tmp_path):
- import os
-
test_file = tmp_path / "test.ts"
with patch("os.lstat", side_effect=OSError("Mock OS Error")):
@@ -289,8 +277,6 @@ def test_scan_file_lstat_oserror(tmp_path):
def test_scan_file_large_file(tmp_path):
- import os
-
test_file = tmp_path / "large.ts"
class MockStat:
@@ -302,7 +288,6 @@ class MockStat:
def test_scan_file_not_regular(tmp_path):
- import os
import stat
test_file = tmp_path / "fifo"
@@ -369,6 +354,24 @@ def test_main_review(monkeypatch):
mock_review.assert_called_once()
+def test_main_report(monkeypatch, tmp_path):
+ findings_file = tmp_path / "findings.json"
+ findings_file.write_text("[]")
+ test_args = [
+ "appguardrail",
+ "report",
+ "buyer-diligence",
+ "--findings",
+ str(findings_file),
+ ]
+ monkeypatch.setattr(sys, "argv", test_args)
+ with patch("scanner.cli.appguardrail.cmd_report", return_value=0) as mock_report:
+ with pytest.raises(SystemExit) as exc:
+ main()
+ assert exc.value.code == 0
+ mock_report.assert_called_once()
+
+
def test_main_hook(monkeypatch):
test_args = ["appguardrail", "hook"]
monkeypatch.setattr(sys, "argv", test_args)
diff --git a/tests/test_external_core.py b/tests/test_external_core.py
new file mode 100644
index 0000000..7650867
--- /dev/null
+++ b/tests/test_external_core.py
@@ -0,0 +1,72 @@
+from appguardrail_core.external import build_external_scan_plan
+
+
+def _available(*names):
+ selected = set(names)
+
+ def checker(name, version_args=("--version",)):
+ return f"/usr/bin/{name}" if name in selected else None
+
+ return checker
+
+
+def test_build_external_scan_plan_auto_selects_python_web_engines():
+ plan = build_external_scan_plan(
+ {"python", "web"},
+ external_mode="auto",
+ zap_baseline_url="https://example.test",
+ tool_available=_available("bandit", "ruff", "semgrep", "zap-baseline.py"),
+ )
+
+ assert plan.selected_names == ("bandit", "ruff", "semgrep", "zap")
+ assert plan.bandit.auto_selected is True
+ assert plan.ruff.auto_selected is True
+ assert plan.semgrep.auto_selected is True
+ assert plan.zap.auto_selected is True
+ assert plan.trivy.should_run is False
+
+
+def test_build_external_scan_plan_reports_missing_optional_auto_tools():
+ plan = build_external_scan_plan(
+ {"python"},
+ external_mode="auto",
+ tool_available=_available(),
+ )
+
+ assert plan.selected_names == ()
+ assert plan.bandit.auto_applicable is True
+ assert plan.bandit.skip_reason == "executable not found or not runnable"
+ assert plan.ruff.skip_reason == "executable not found or not runnable"
+ assert plan.semgrep.skip_reason == "executable not found or not runnable"
+ assert plan.zap.skip_reason is None
+
+
+def test_build_external_scan_plan_forced_engines_run_even_when_unavailable():
+ plan = build_external_scan_plan(
+ set(),
+ external_mode="off",
+ force_trivy=True,
+ force_bandit=True,
+ force_ruff=True,
+ force_semgrep=True,
+ zap_baseline_url="https://example.test",
+ force_zap=True,
+ tool_available=_available(),
+ )
+
+ assert plan.selected_names == ("trivy", "bandit", "ruff", "semgrep", "zap")
+ assert all(decision.forced for decision in plan.decisions)
+ assert not any(decision.available for decision in plan.decisions)
+
+
+def test_build_external_scan_plan_java_typescript_needs_semgrep_not_python_tools():
+ plan = build_external_scan_plan(
+ {"java", "typescript"},
+ external_mode="auto",
+ tool_available=_available("semgrep"),
+ )
+
+ assert plan.selected_names == ("semgrep",)
+ assert plan.bandit.auto_applicable is False
+ assert plan.ruff.auto_applicable is False
+ assert plan.semgrep.auto_applicable is True
diff --git a/tests/test_findings_core.py b/tests/test_findings_core.py
new file mode 100644
index 0000000..8fb6823
--- /dev/null
+++ b/tests/test_findings_core.py
@@ -0,0 +1,79 @@
+from appguardrail_core.findings import (
+ finding_sort_key,
+ is_deploy_blocking,
+ normalize_finding,
+ normalize_findings,
+ safe_report_snippet,
+ severity_counts,
+)
+
+
+def test_normalize_finding_adds_report_contract_defaults():
+ finding = normalize_finding(
+ {
+ "severity": "high",
+ "rule_id": "python-requests-verify-false",
+ "message": "TLS verification disabled.",
+ "file": "client.py",
+ "line": 7,
+ "references": "CWE-295 - Improper Certificate Validation",
+ "fix_prompt": "Keep certificate verification enabled.",
+ }
+ )
+
+ assert finding["severity"] == "HIGH"
+ assert finding["category"] == "misconfig"
+ assert finding["context"] == "app-code"
+ assert finding["references"] == ("CWE-295 - Improper Certificate Validation",)
+ assert finding["remediation"] == "Keep certificate verification enabled."
+ assert finding["verification"] == "Rerun AppGuardrail after remediation."
+
+
+def test_normalize_findings_returns_stable_tuple():
+ normalized = normalize_findings(
+ [
+ {"rule_id": "one", "severity": "INFO"},
+ {"rule_id": "two", "severity": "WARNING"},
+ ]
+ )
+
+ assert isinstance(normalized, tuple)
+ assert [finding["rule_id"] for finding in normalized] == ["one", "two"]
+
+
+def test_severity_counts_folds_unknown_values_into_info():
+ counts = severity_counts(
+ [
+ {"severity": "CRITICAL"},
+ {"severity": "medium"},
+ {"severity": ""},
+ {},
+ ]
+ )
+
+ assert counts == {"CRITICAL": 1, "HIGH": 0, "WARNING": 0, "INFO": 3}
+
+
+def test_is_deploy_blocking_uses_context_and_case_insensitive_severity():
+ assert is_deploy_blocking({"severity": "critical", "context": "app-code"})
+ assert is_deploy_blocking({"severity": "HIGH"})
+ assert not is_deploy_blocking({"severity": "HIGH", "context": "doc"})
+ assert not is_deploy_blocking({"severity": "WARNING", "context": "app-code"})
+
+
+def test_finding_sort_key_orders_by_deploy_severity_then_category_and_rule():
+ findings = [
+ {"severity": "INFO", "category": "z", "rule_id": "z"},
+ {"severity": "HIGH", "category": "authz", "rule_id": "b"},
+ {"severity": "CRITICAL", "category": "secrets", "rule_id": "a"},
+ {"severity": "HIGH", "category": "authz", "rule_id": "a"},
+ ]
+
+ ordered = sorted(findings, key=finding_sort_key)
+
+ assert [finding["rule_id"] for finding in ordered] == ["a", "a", "b", "z"]
+
+
+def test_safe_report_snippet_trims_without_changing_short_text():
+ assert safe_report_snippet("short evidence") == "short evidence"
+ assert safe_report_snippet("x" * 410, max_len=20) == "x" * 20 + "\n...[truncated]"
diff --git a/tests/test_issueops_core.py b/tests/test_issueops_core.py
new file mode 100644
index 0000000..9200eed
--- /dev/null
+++ b/tests/test_issueops_core.py
@@ -0,0 +1,86 @@
+from appguardrail_core import issueops
+
+
+def finding(**overrides):
+ base = {
+ "repo": "ContextualWisdomLab/naruon",
+ "workflow": "Strix Security Scan",
+ "run_id": 28492006630,
+ "run_url": "https://github.com/ContextualWisdomLab/naruon/actions/runs/28492006630",
+ "job_id": 84450511793,
+ "job_name": "strix",
+ "job_url": "https://github.com/ContextualWisdomLab/naruon/actions/runs/28492006630/job/84450511793",
+ "conclusion": "failure",
+ "branch": "develop",
+ "head_sha": "abc123",
+ "event": "pull_request",
+ "pr_numbers": [265],
+ "snippet": "VULN-0001 CRITICAL example",
+ }
+ base.update(overrides)
+ return base
+
+
+def test_security_scope_conclusions_and_run_url_pattern():
+ for name in ("Strix", "OpenCode Review", "AppGuardRail", "Trivy FS", "CodeQL", "Security Process"):
+ assert issueops.is_security_name(name)
+ assert issueops.is_security_name("Java CI", "typescript CodeQL analyze")
+ assert not issueops.is_security_name("pytest", "build")
+ assert all(issueops.is_failure(value) for value in ("failure", "cancelled", "timed_out", "action_required"))
+ assert not any(issueops.is_failure(value) for value in ("success", "skipped", None))
+ repo, run_id = issueops.parse_run_url(
+ "https://github.com/ContextualWisdomLab/naruon/actions/runs/28492006630/job/84450511793#step:21:1"
+ )
+ assert (repo, run_id) == ("ContextualWisdomLab/naruon", 28492006630)
+
+
+def test_redaction_and_log_compression_prioritize_security_context():
+ secret_log = (
+ "\x1b[31m2026-07-01T10:20:30.123Z Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz\n"
+ "token='github_pat_abcdefghijklmnopqrstuvwxyz0123456789'\n"
+ "jwt=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature\n"
+ )
+ redacted = issueops.redact(secret_log)
+ assert "\x1b" not in redacted
+ assert "2026-07-01T10:20:30.123Z" not in redacted
+ assert "ghp_" not in redacted and "github_pat_" not in redacted and "eyJhbGci" not in redacted
+
+ log = "\n".join(
+ [
+ 'echo "::error::source branch should not dominate"',
+ *[f"noise {i}" for i in range(12)],
+ "Unable to map Strix findings",
+ "VULN-0001 CRITICAL browser storage issue",
+ "RateLimitError: retry budget exhausted",
+ *[f"tail noise {i}" for i in range(12)],
+ "::error::actual security failure",
+ ]
+ )
+ snippet = issueops.compress_log(log, max_lines=28, max_chars=5000)
+ assert "VULN-0001 CRITICAL" in snippet
+ assert "RateLimitError" in snippet
+ assert "::error::actual security failure" in snippet
+ assert 'echo "::error::source branch should not dominate"' not in snippet
+ assert "...[compressed]" in snippet
+
+
+def test_marker_body_and_replacement_round_trip():
+ item = finding()
+ body = issueops.issue_body(item, {issueops.seen_key(item)})
+ assert "