Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
felickz marked this conversation as resolved.

- 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`.
Comment thread
felickz marked this conversation as resolved.

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]

Expand Down
29 changes: 28 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
40 changes: 36 additions & 4 deletions ghascompliance/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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__":
Expand Down Expand Up @@ -197,25 +203,40 @@
)

errors = 0
total_violations = 0
total_errors = 0
check_results = {}

checks = [
checks_to_run = [
("code_scanning", checks.checkCodeScanning),
("dependabot", checks.checkDependabot),
("dependencies", checks.checkDependencies),
("dependency_licensing", checks.checkDependencyLicensing),
("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)))
Expand All @@ -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)
Expand All @@ -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
Expand Down
38 changes: 38 additions & 0 deletions ghascompliance/output.py
Original file line number Diff line number Diff line change
@@ -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")
55 changes: 55 additions & 0 deletions tests/test_output.py
Original file line number Diff line number Diff line change
@@ -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"))
Loading