diff --git a/README.md b/README.md index 3cba35d..9f7c237 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,65 @@ Here is how you can quickly setup policy-as-code. uses: advanced-security/policy-as-code@v2.11.1 ``` +#### Structured results + +The action exposes its results as JSON for downstream steps through the `results` +output. It also writes the same JSON to `.compliance/results.json` by default. +Set the `output` input to use a different file location. + +```yaml +- name: Advanced Security Policy as Code + id: policy + uses: advanced-security/policy-as-code@v2.11.1 + +- name: Read total violations + run: echo '${{ fromJSON(steps.policy.outputs.results).total_violations }}' +``` + +The JSON schema is: + +```json +{ + "schema_version": 1, + "total_violations": 2, + "total_errors": 1, + "checks": { + "code_scanning": { + "status": "success", + "violations": 1 + }, + "dependabot": { + "status": "success", + "violations": 1 + }, + "secret_scanning": { + "status": "error", + "violations": 0, + "error": "Authentication Error" + } + } +} +``` + +`checks` includes each enabled check with its `status` (`success` or `error`) and +violation count. Checks that fail with an error also include an `error` message, +are counted in `total_errors`, and are not included in `total_violations`. + +If the results file is missing, for example because the run failed before writing +it, the action emits a fallback payload instead. `total_violations` and +`total_errors` are `null` since the real counts are unknown, `checks` is empty, +and a top-level `error` describes the failure: + +```json +{ + "schema_version": 1, + "total_violations": null, + "total_errors": null, + "checks": {}, + "error": "Results file was not found; the run may have failed before writing results" +} +``` + > [!WARNING] > The GitHub Action does not install Python on the runner. Please checkout at [the `actions/setup-python` Action][python-setup] diff --git a/action.yml b/action.yml index 5581008..3f4d057 100644 --- a/action.yml +++ b/action.yml @@ -42,10 +42,20 @@ inputs: argvs: description: "Additional Arguments" + output: + description: Path where structured JSON results are written + default: .compliance/results.json + +outputs: + results: + description: Structured JSON results, with schema_version, total_violations, total_errors, and per-check results. If the results file is missing (for example, the run failed before writing it), a fallback payload is emitted instead with total_violations and total_errors set to null and a top-level error message describing the failure. + value: ${{ steps.results.outputs.results }} + runs: using: "composite" steps: - - shell: bash + - id: policy + shell: bash run: | echo "Running Policy as Code..." export PYTHONPATH=${{ github.action_path }}:${{ github.action_path }}/vendor @@ -60,4 +70,21 @@ runs: --github-policy-path "${{ inputs.policy-path }}" \ --github-policy-branch "${{ inputs.policy-branch }}" \ --retry-count "${{ inputs.retries }}" \ + --output "${{ inputs.output }}" \ ${{ inputs.argvs }} + - id: results + if: always() + shell: bash + env: + RESULTS_FILE: ${{ inputs.output }} + run: | + delimiter="$(python -c 'import secrets; print(secrets.token_hex(16))' 2>/dev/null || python3 -c 'import secrets; print(secrets.token_hex(16))')" + { + printf 'results<<%s\n' "$delimiter" + if [ -f "$RESULTS_FILE" ]; then + cat "$RESULTS_FILE" + else + printf '%s\n' '{"schema_version":1,"total_violations":null,"total_errors":null,"checks":{},"error":"Results file was not found; the run may have failed before writing results"}' + fi + printf '%s\n' "$delimiter" + } >> "$GITHUB_OUTPUT" diff --git a/ghascompliance/__main__.py b/ghascompliance/__main__.py index 83dc8c2..19b01b5 100644 --- a/ghascompliance/__main__.py +++ b/ghascompliance/__main__.py @@ -9,6 +9,7 @@ from ghascompliance.octokit import Octokit, PullRequest, Summary from ghascompliance.policy import Policy from ghascompliance.checks import * +from ghascompliance.output import write_results # https://docs.github.com/en/actions/reference/environment-variables#default-environment-variables GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") @@ -65,6 +66,11 @@ thresholds.add_argument("--severity", default="Error") thresholds.add_argument("--list-severities", action="store_true") thresholds.add_argument("--count", type=int, default=-1) +parser.add_argument( + "--output", + default=os.path.join(".compliance", "results.json"), + help="Path to write structured policy check results as JSON", +) if __name__ == "__main__": @@ -197,8 +203,11 @@ ) errors = 0 + total_violations = 0 + total_errors = 0 + check_results = {} - checks = [ + checks_to_run = [ ("code_scanning", checks.checkCodeScanning), ("dependabot", checks.checkDependabot), ("dependencies", checks.checkDependencies), @@ -206,16 +215,28 @@ ("secret_scanning", checks.checkSecretScanning), ] - for check in checks: + for check_name, check_fn in checks_to_run: try: - if not getattr(arguments, f"disable_{check[0]}"): - errors += check[1]() + if not getattr(arguments, f"disable_{check_name}"): + violations = check_fn() + errors += violations + total_violations += violations + check_results[check_name] = { + "status": "success", + "violations": violations, + } except GHASToolkitAuthenticationError as err: Octokit.error("Authentication Error") Octokit.error(str(err)) errors += 1 + total_errors += 1 + check_results[check_name] = { + "status": "error", + "violations": 0, + "error": str(err), + } # Add to summary Summary.addLine(f"{Summary.__ICONS__['cross']} :: Authentication Error") Summary.addLine(Summary.formatItalics(str(err))) @@ -225,6 +246,12 @@ Octokit.error(str(err)) errors += 1 # add to error count + total_errors += 1 + check_results[check_name] = { + "status": "error", + "violations": 0, + "error": str(err), + } # Add to summary Summary.addHeader(f"{Summary.__ICONS__['cross']} :: Error Encountered", 2) @@ -238,6 +265,11 @@ Octokit.endGroup() + try: + write_results(arguments.output, total_violations, total_errors, check_results) + except OSError as err: + Octokit.warning(f"Unable to write results file :: {err}") + Octokit.createGroup("Summary") # Summary and PR comment diff --git a/ghascompliance/output.py b/ghascompliance/output.py new file mode 100644 index 0000000..63b18f4 --- /dev/null +++ b/ghascompliance/output.py @@ -0,0 +1,38 @@ +import json +import os +from typing import Any, Dict + + +def write_results( + path: str, + total_violations: int, + total_errors: int, + checks: Dict[str, Dict[str, Any]], +) -> None: + """Write policy check results to a JSON file. + + Args: + path: File path to write the JSON results to. Missing parent + directories are created. + total_violations: Total number of policy violations found by the + checks that ran successfully. + total_errors: Number of checks that failed with an error. + checks: Per-check results, keyed by check name, each containing the + check `status`, its `violations` count and, on failure, an `error`. + """ + output_directory = os.path.dirname(path) + if output_directory: + os.makedirs(output_directory, exist_ok=True) + + with open(path, "w", encoding="utf-8") as handle: + json.dump( + { + "schema_version": 1, + "total_violations": total_violations, + "total_errors": total_errors, + "checks": checks, + }, + handle, + indent=2, + ) + handle.write("\n") diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 0000000..a9b8107 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,55 @@ +import json +import os +import tempfile +import unittest + +from ghascompliance.output import write_results + + +class TestOutput(unittest.TestCase): + def test_write_results_creates_structured_json(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "results", "output.json") + + write_results( + path, + 2, + 1, + { + "code_scanning": {"status": "success", "violations": 1}, + "dependabot": {"status": "success", "violations": 1}, + "secret_scanning": { + "status": "error", + "violations": 0, + "error": "Authentication Error", + }, + }, + ) + + with open(path, encoding="utf-8") as handle: + self.assertEqual( + json.load(handle), + { + "schema_version": 1, + "total_violations": 2, + "total_errors": 1, + "checks": { + "code_scanning": {"status": "success", "violations": 1}, + "dependabot": {"status": "success", "violations": 1}, + "secret_scanning": { + "status": "error", + "violations": 0, + "error": "Authentication Error", + }, + }, + }, + ) + + def test_write_results_ends_with_newline(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "output.json") + + write_results(path, 0, 0, {}) + + with open(path, encoding="utf-8") as handle: + self.assertTrue(handle.read().endswith("\n"))