From 968910bae7e0f88bf6b363e010a48d2fe969a68c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20B=2E=20Pereira?= Date: Tue, 7 Jul 2026 13:12:31 +0200 Subject: [PATCH] release: v0.5.0 (M.A.R.I.A policy import + risk model, HTML report, security hardening) Added - M.A.R.I.A policy import (--maria-import-policy, --maria-policy-url) and maria_riskscore_v1 risk-score model (weights, application context, risk profile). - Optional self-contained HTML report (--html-output / --html-out) and matching GitHub Action inputs. - Console entry point `secscore pr ...`. Changed / Fixed - PR comment renderer now honors the policy `reporting` block (max_findings_in_comment, max_reasons, include_fields) instead of fixed defaults; include_fields is now actually applied to finding lines. Security - SSRF hardening on M.A.R.I.A outbound requests (scheme/credential checks, block private/reserved targets, explicit opt-in for local URLs). - Path-access hardening for CLI and SARIF inputs (confined to workspace/CI roots). - Composite action hardened against injection (env-var interpolation + quoted arg arrays). - PR comment hardened against Markdown injection from untrusted scanner output (escaping + percent-encoded link targets). Repository - Bumped version to 0.5.0 (pyproject + secscore.__version__). - Added .gitattributes (LF) and ignored generated artifacts; stopped tracking tmp-result.json. Co-Authored-By: Claude Opus 4.8 --- .gitattributes | 16 + .github/workflows/ci.yml | 3 + .gitignore | 7 + CHANGELOG.md | 53 ++ CODEOWNERS | 1 + README.md | 72 ++- README.pt-br.md | 67 +- action.yml | 161 ++--- .../reporting-config-ignored-in-pr-comment.md | 43 ++ pyproject.toml | 7 +- secscore/__init__.py | 2 +- secscore/adapters/maria_provider.py | 192 +++++- secscore/cli/main.py | 558 ++++++++++++----- secscore/core/engine.py | 105 +++- secscore/core/reporting.py | 592 +++++++++++++++++- secscore/normalizers/sarif.py | 19 +- tests/test_maria_integration.py | 241 ++++++- tests/test_reporting.py | 93 +++ tmp-result.json | 13 - 19 files changed, 1956 insertions(+), 289 deletions(-) create mode 100644 .gitattributes create mode 100644 CODEOWNERS create mode 100644 docs/ai-memory/reporting-config-ignored-in-pr-comment.md create mode 100644 tests/test_reporting.py delete mode 100644 tmp-result.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7666589 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +# Normalize line endings: store LF in the repository and check out LF, +# so shell blocks in action.yml and CI stay valid on Linux runners +# regardless of the contributor's OS or core.autocrlf setting. +* text=auto eol=lf + +# Keep Windows-native scripts as CRLF (none today, listed for clarity). +*.bat text eol=crlf +*.cmd text eol=crlf + +# Treat binaries explicitly so git never touches their bytes. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d83a76a..ab0cd57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,9 @@ on: push: pull_request: +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 1896c7f..c57353a 100644 --- a/.gitignore +++ b/.gitignore @@ -212,6 +212,13 @@ __marimo__/ *.sarif pr-comment.md secscore-result.json +secscore-report.html + +# Generated at runtime by the M.A.R.I.A policy import (overwritten every run) +policy/policy-maria.yml + +# Scratch / test artifacts +tmp-* # Allow SARIF examples !examples/*.sarif diff --git a/CHANGELOG.md b/CHANGELOG.md index 77f8159..65f7b91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,59 @@ The format is based on semantic versioning and follows a simple chronological re --- +## v0.5.0 — 2026-07 + +### Added + +* Console entry point for installed environments: + `secscore pr ...` can now be used instead of `python -m secscore.cli.main pr ...`. +* Optional HTML report output generated from the standard JSON result: + `--html-output true` and `--html-out secscore-report.html`. +* GitHub Action inputs `html_output` and `html_out` for publishing the visual report as a workflow artifact. +* HTML report polish: dark/light toggle, risk summary, linked findings and artifacts, + copy JSON path action, collapsible execution parameters, and report footer metadata. +* Optional **M.A.R.I.A policy import** with `--maria-import-policy` and `--maria-policy-url`, + allowing SecScore to sync repository risk policy before scoring. +* M.A.R.I.A risk score model support (`maria_riskscore_v1`) including risk weights, + application context, and risk profile multipliers. + +### Changed + +* Package version bumped to `0.5.0`. +* GitHub Action now documents the workflow permissions needed for checks, PR comments, + and labels. +* CI workflow now declares explicit least-privilege read access with `permissions: contents: read`. +* PR comment renderer now honors the policy `reporting` block + (`max_findings_in_comment`, `max_reasons`, `include_fields`) instead of always + falling back to fixed defaults. + +### Fixed + +* `include_fields` in the policy `reporting` block is now actually applied when + rendering finding lines (location and metadata fields), instead of being ignored. + +### Security + +* Hardened M.A.R.I.A outbound requests against server-side request forgery by validating + URL schemes, rejecting embedded credentials, and blocking local/private/reserved network + targets by default. Private/local URLs require explicit trusted opt-in with + `SECSCORE_ALLOW_PRIVATE_MARIA_URLS=true`. +* Hardened CLI and SARIF file handling against uncontrolled path access by resolving paths + and requiring them to stay inside the current workspace or approved CI roots. +* Hardened the GitHub composite action against code injection by moving input interpolation + into environment variables and passing CLI arguments through quoted Bash arrays. +* Hardened the PR comment against Markdown injection from untrusted scanner output by + escaping finding titles, paths and metadata, and percent-encoding link targets. + +### Repository + +* Added `.gitattributes` enforcing LF line endings so the composite action's Bash steps + stay valid on Linux runners regardless of contributor OS. +* Ignored generated artifacts (`secscore-report.html`, runtime `policy/policy-maria.yml`, + `tmp-*`) and stopped tracking `tmp-result.json`. + +--- + ## v0.4.0 — 2026-04 ### Added diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..48ff929 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @cassiodeveloper \ No newline at end of file diff --git a/README.md b/README.md index c27f748..9acd1ac 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ Install dependencies: ``` pip install -r requirements.txt +pip install -e . ``` --- @@ -116,7 +117,7 @@ pip install -r requirements.txt 1. Run with SARIF and policy: ```bash -python -m secscore.cli.main pr \ +secscore pr \ --sarif tests/fixtures/review.sarif \ --policy policy/policy-pr.yml \ --no-diff-aware @@ -125,11 +126,12 @@ python -m secscore.cli.main pr \ 2. Check outputs: - `pr-comment.md` (PR-ready markdown summary) - `secscore-result.json` (structured result) +- Optional: `secscore-report.html` (visual report generated when `--html-output true`) 3. Optional: submit result to M.A.R.I.A: ```bash -python -m secscore.cli.main pr \ +SECSCORE_ALLOW_PRIVATE_MARIA_URLS=true secscore pr \ --sarif tests/fixtures/review.sarif \ --policy policy/policy-pr.yml \ --maria-url http://localhost:5213/api/secscore/submissions \ @@ -147,7 +149,7 @@ Use these commands to validate expected outcomes quickly: ### PASS ```bash -python -m secscore.cli.main pr \ +secscore pr \ --sarif tests/fixtures/pass.sarif \ --policy policy/policy-pr.yml \ --no-diff-aware @@ -158,7 +160,7 @@ Expected: `Decision: PASS` ### REVIEW ```bash -python -m secscore.cli.main pr \ +secscore pr \ --sarif tests/fixtures/review.sarif \ --policy policy/policy-pr.yml \ --no-diff-aware @@ -169,7 +171,7 @@ Expected: `Decision: REVIEW` ### FAIL ```bash -python -m secscore.cli.main pr \ +secscore pr \ --sarif tests/fixtures/fail.sarif \ --policy policy/policy-pr.yml \ --no-diff-aware @@ -194,7 +196,7 @@ Expected: `Decision: FAIL` Single SARIF file: ``` -python -m secscore.cli.main pr \ +secscore pr \ --sarif examples/example-snyk.sarif \ --policy policy/policy-pr.yml \ --no-diff-aware @@ -203,7 +205,7 @@ python -m secscore.cli.main pr \ Multiple SARIF files (v0.3.0+): ``` -python -m secscore.cli.main pr \ +secscore pr \ --sarif semgrep.sarif,trivy.sarif \ --policy policy/policy-pr.yml \ --no-diff-aware @@ -212,7 +214,7 @@ python -m secscore.cli.main pr \ Send consolidated findings to M.A.R.I.A (token provided at invocation): ```bash -python -m secscore.cli.main pr \ +secscore pr \ --sarif semgrep.sarif,trivy.sarif \ --policy policy/policy-pr.yml \ --maria-url https://demo.mariaappsec.com/api/secscore/submissions \ @@ -226,10 +228,18 @@ For `/api/secscore/submissions`, SecScore auto-fills required submission fields You can override them with: `--maria-submission-key`, `--maria-commit-sha`, `--maria-branch-name`, `--maria-pipeline-name`, `--maria-pipeline-run-id`, `--maria-pull-request-id`. +### M.A.R.I.A policy import behavior + +- When M.A.R.I.A integration is configured (`--maria-url`, `--maria-repository-id`, `--token`/`--maria-token`), + SecScore imports policy from M.A.R.I.A by default. +- The imported policy is saved on every run to `policy/policy-maria.yml`. +- The execution then uses `policy/policy-maria.yml` as the effective policy. +- Use `--maria-import-policy false` to keep using the local policy file from `--policy`. + For local PR testing without opening a real PR: ```bash -SECSCORE_PULL_REQUEST_ID=local-pr-001 python -m secscore.cli.main pr \ +SECSCORE_ALLOW_PRIVATE_MARIA_URLS=true SECSCORE_PULL_REQUEST_ID=local-pr-001 secscore pr \ --sarif semgrep.sarif \ --policy policy/policy-pr.yml \ --maria-url http://localhost:5213/api/secscore/submissions \ @@ -252,10 +262,36 @@ Score: 85 / 100 Decision: PASS ``` +Generate a visual HTML report from the standard JSON output: + +```bash +secscore pr \ + --sarif tests/fixtures/review.sarif \ + --policy policy/policy-pr.yml \ + --no-diff-aware \ + --html-output true +``` + +The JSON result is always generated. When HTML output is enabled, SecScore also writes +`secscore-report.html` by default. Use `--html-out custom-report.html` to choose another path. + --- ## GitHub Action +Recommended workflow permissions: + +```yaml +permissions: + contents: read + checks: write + pull-requests: write + issues: write +``` + +SecScore needs `contents: read` to access the repository, `checks: write` to create the status check, +and `issues: write`/`pull-requests: write` to upsert PR comments and manage the review label. + Minimal example: ```yaml @@ -281,6 +317,24 @@ Multiple scanners (v0.3.0+): maria-token: ${{ secrets.MARIA_TOKEN }} ``` +Generate and publish the HTML report as a workflow artifact: + +```yaml +- name: Run SecScore + uses: cassiodeveloper/secscore@v1 + with: + sarif: results.sarif + html_output: "true" + +- name: Upload SecScore report + uses: actions/upload-artifact@v4 + with: + name: secscore-report + path: | + secscore-result.json + secscore-report.html +``` + Disable diff-aware: ```yaml diff --git a/README.pt-br.md b/README.pt-br.md index 31f75d7..8cb995f 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -35,7 +35,7 @@ Ele transforma findings de scanners em uma decisão objetiva de PR: **PASS / REV 1. Execute com SARIF + policy: ```bash -python -m secscore.cli.main pr \ +secscore pr \ --sarif tests/fixtures/review.sarif \ --policy policy/policy-pr.yml \ --no-diff-aware @@ -44,11 +44,12 @@ python -m secscore.cli.main pr \ 2. Confira os artefatos: - `pr-comment.md` (resumo para comentário de PR) - `secscore-result.json` (resultado estruturado) +- Opcional: `secscore-report.html` (relatório visual gerado com `--html-output true`) 3. Opcional: enviar para M.A.R.I.A: ```bash -python -m secscore.cli.main pr \ +SECSCORE_ALLOW_PRIVATE_MARIA_URLS=true secscore pr \ --sarif tests/fixtures/review.sarif \ --policy policy/policy-pr.yml \ --maria-url http://localhost:5213/api/secscore/submissions \ @@ -66,7 +67,7 @@ Use estes comandos para validar rapidamente os comportamentos esperados: ### PASS ```bash -python -m secscore.cli.main pr \ +secscore pr \ --sarif tests/fixtures/pass.sarif \ --policy policy/policy-pr.yml \ --no-diff-aware @@ -77,7 +78,7 @@ Esperado: `Decision: PASS` ### REVIEW ```bash -python -m secscore.cli.main pr \ +secscore pr \ --sarif tests/fixtures/review.sarif \ --policy policy/policy-pr.yml \ --no-diff-aware @@ -88,7 +89,7 @@ Esperado: `Decision: REVIEW` ### FAIL ```bash -python -m secscore.cli.main pr \ +secscore pr \ --sarif tests/fixtures/fail.sarif \ --policy policy/policy-pr.yml \ --no-diff-aware @@ -111,16 +112,29 @@ Esperado: `Decision: FAIL` Exemplo com SARIF: ```bash -python -m secscore.cli.main pr \ +secscore pr \ --sarif semgrep.sarif,trivy.sarif \ --policy policy/policy-pr.yml \ --no-diff-aware ``` +Gerar também um relatório HTML visual a partir do JSON padrão: + +```bash +secscore pr \ + --sarif tests/fixtures/review.sarif \ + --policy policy/policy-pr.yml \ + --no-diff-aware \ + --html-output true +``` + +O JSON é sempre gerado. Quando o HTML está ativado, o SecScore também grava +`secscore-report.html` por padrão. Use `--html-out meu-relatorio.html` para escolher outro caminho. + Exemplo com envio para M.A.R.I.A: ```bash -python -m secscore.cli.main pr \ +secscore pr \ --sarif semgrep.sarif,trivy.sarif \ --policy policy/policy-pr.yml \ --maria-url https://demo.mariaappsec.com/api/secscore/submissions \ @@ -139,10 +153,31 @@ Flags úteis de override: - `--maria-pipeline-run-id` - `--maria-pull-request-id` +### Comportamento de import de policy do M.A.R.I.A + +- Quando a integração com M.A.R.I.A está configurada (`--maria-url`, `--maria-repository-id`, `--token`/`--maria-token`), + o SecScore importa a policy do M.A.R.I.A por padrão. +- A policy importada é salva em toda execução em `policy/policy-maria.yml`. +- A execução passa a usar `policy/policy-maria.yml` como policy efetiva. +- Use `--maria-import-policy false` para continuar usando a policy local informada em `--policy`. + --- ## GitHub Action +Permissões recomendadas no workflow: + +```yaml +permissions: + contents: read + checks: write + pull-requests: write + issues: write +``` + +O SecScore usa `contents: read` para acessar o repositório, `checks: write` para criar o status check, +e `issues: write`/`pull-requests: write` para atualizar comentários de PR e gerenciar a label de revisão. + ```yaml - uses: actions/checkout@v4 with: @@ -157,6 +192,24 @@ Flags úteis de override: maria-token: ${{ secrets.MARIA_TOKEN }} ``` +Gerar e publicar o relatório HTML como artifact do workflow: + +```yaml +- name: Run SecScore + uses: cassiodeveloper/secscore@v1 + with: + sarif: results.sarif + html_output: "true" + +- name: Upload SecScore report + uses: actions/upload-artifact@v4 + with: + name: secscore-report + path: | + secscore-result.json + secscore-report.html +``` + --- ## Política (policy) diff --git a/action.yml b/action.yml index 0227590..5fda48c 100644 --- a/action.yml +++ b/action.yml @@ -57,6 +57,9 @@ inputs: maria-token: description: "M.A.R.I.A bearer token (overrides token)" required: false + maria-import-policy: + description: "Set to 'true' or 'false' to control M.A.R.I.A policy import behavior" + required: false maria-repository-id: description: "M.A.R.I.A repository id (GUID) for submissions endpoint" required: false @@ -66,6 +69,14 @@ inputs: branch: description: "Branch name for context" required: false + html_output: + description: "Set to 'true' to generate secscore-report.html from secscore-result.json" + required: false + default: "false" + html_out: + description: "Path to the generated HTML report" + required: false + default: "secscore-report.html" outputs: decision: @@ -96,8 +107,30 @@ runs: - name: Run SecScore id: secscore shell: bash + env: + INPUT_POLICY: ${{ inputs.policy }} + INPUT_SARIF: ${{ inputs.sarif }} + INPUT_FINDINGS: ${{ inputs.findings }} + INPUT_PROVIDER: ${{ inputs.provider }} + INPUT_NO_DIFF_AWARE: ${{ inputs.no_diff_aware }} + INPUT_BASE_REF: ${{ inputs.base_ref }} + INPUT_BRANCH: ${{ inputs.branch }} + GITHUB_HEAD_REF_VALUE: ${{ github.head_ref }} + INPUT_HTML_OUTPUT: ${{ inputs.html_output }} + INPUT_HTML_OUT: ${{ inputs.html_out }} + INPUT_CHECKMARX_PROJECT: ${{ inputs.checkmarx-project }} + INPUT_CHECKMARX_BASE_URL: ${{ inputs.checkmarx-base-url }} + INPUT_CHECKMARX_TOKEN: ${{ inputs.checkmarx-token }} + INPUT_CHECKMARX_TENANT: ${{ inputs.checkmarx-tenant }} + INPUT_TOKEN: ${{ inputs.token }} + INPUT_MARIA_URL: ${{ inputs.maria-url }} + INPUT_MARIA_TOKEN: ${{ inputs.maria-token }} + INPUT_MARIA_IMPORT_POLICY: ${{ inputs.maria-import-policy }} + INPUT_MARIA_REPOSITORY_ID: ${{ inputs.maria-repository-id }} + INPUT_MARIA_PULL_REQUEST_ID: ${{ inputs.maria-pull-request-id }} + GITHUB_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} run: | - POLICY_PATH="${{ inputs.policy }}" + POLICY_PATH="${INPUT_POLICY}" if [ -z "$POLICY_PATH" ]; then POLICY_PATH="${GITHUB_ACTION_PATH}/policy/policy-default.yml" echo "Using default policy from action" @@ -106,112 +139,94 @@ runs: fi # Monta argumento --sarif (pode ser lista separada por vírgula) - SARIF_ARG="" - if [ -n "${{ inputs.sarif }}" ]; then - SARIF_ARG="--sarif ${{ inputs.sarif }}" + args=() + if [ -n "${INPUT_SARIF}" ]; then + args+=(--sarif "${INPUT_SARIF}") fi - - FINDINGS_ARG="" - if [ -n "${{ inputs.findings }}" ]; then - FINDINGS_ARG="--findings ${{ inputs.findings }}" + if [ -n "${INPUT_FINDINGS}" ]; then + args+=(--findings "${INPUT_FINDINGS}") fi - - PROVIDER_ARG="" - if [ -n "${{ inputs.provider }}" ]; then - PROVIDER_ARG="--provider ${{ inputs.provider }}" + if [ -n "${INPUT_PROVIDER}" ]; then + args+=(--provider "${INPUT_PROVIDER}") fi # Diff-aware opt-out - DIFF_ARG="" - if [ "${{ inputs.no_diff_aware }}" = "true" ]; then - DIFF_ARG="--no-diff-aware" + if [ "${INPUT_NO_DIFF_AWARE}" = "true" ]; then + args+=(--no-diff-aware) fi # Base ref - BASE_REF_ARG="" - if [ -n "${{ inputs.base_ref }}" ]; then - BASE_REF_ARG="--base-ref ${{ inputs.base_ref }}" + if [ -n "${INPUT_BASE_REF}" ]; then + args+=(--base-ref "${INPUT_BASE_REF}") fi - BRANCH_ARG="" - if [ -n "${{ inputs.branch }}" ]; then - BRANCH_ARG="--branch ${{ inputs.branch }}" - elif [ -n "${{ github.head_ref }}" ]; then - BRANCH_ARG="--branch ${{ github.head_ref }}" + if [ -n "${INPUT_BRANCH}" ]; then + args+=(--branch "${INPUT_BRANCH}") + elif [ -n "${GITHUB_HEAD_REF_VALUE}" ]; then + args+=(--branch "${GITHUB_HEAD_REF_VALUE}") fi - CHECKMARX_PROJECT_ARG="" - if [ -n "${{ inputs.checkmarx-project }}" ]; then - CHECKMARX_PROJECT_ARG="--checkmarx-project ${{ inputs.checkmarx-project }}" + if [ "${INPUT_HTML_OUTPUT}" = "true" ]; then + args+=(--html-output true) fi - CHECKMARX_BASE_URL_ARG="" - if [ -n "${{ inputs.checkmarx-base-url }}" ]; then - CHECKMARX_BASE_URL_ARG="--checkmarx-base-url ${{ inputs.checkmarx-base-url }}" + if [ -n "${INPUT_HTML_OUT}" ]; then + args+=(--html-out "${INPUT_HTML_OUT}") fi - CHECKMARX_TOKEN_ARG="" - if [ -n "${{ inputs.checkmarx-token }}" ]; then - CHECKMARX_TOKEN_ARG="--checkmarx-token ${{ inputs.checkmarx-token }}" + if [ -n "${INPUT_CHECKMARX_PROJECT}" ]; then + args+=(--checkmarx-project "${INPUT_CHECKMARX_PROJECT}") + fi + + if [ -n "${INPUT_CHECKMARX_BASE_URL}" ]; then + args+=(--checkmarx-base-url "${INPUT_CHECKMARX_BASE_URL}") + fi + + if [ -n "${INPUT_CHECKMARX_TOKEN}" ]; then + args+=(--checkmarx-token "${INPUT_CHECKMARX_TOKEN}") fi # Generic token integration argument - TOKEN_ARG="" - if [ -n "${{ inputs.token }}" ]; then - TOKEN_ARG="--token ${{ inputs.token }}" + if [ -n "${INPUT_TOKEN}" ]; then + args+=(--token "${INPUT_TOKEN}") fi # M.A.R.I.A integration arguments - MARIA_URL_ARG="" - if [ -n "${{ inputs.maria-url }}" ]; then - MARIA_URL_ARG="--maria-url ${{ inputs.maria-url }}" + if [ -n "${INPUT_MARIA_URL}" ]; then + args+=(--maria-url "${INPUT_MARIA_URL}") + fi + + if [ -n "${INPUT_MARIA_TOKEN}" ]; then + args+=(--maria-token "${INPUT_MARIA_TOKEN}") fi - MARIA_TOKEN_ARG="" - if [ -n "${{ inputs.maria-token }}" ]; then - MARIA_TOKEN_ARG="--maria-token ${{ inputs.maria-token }}" + if [ -n "${INPUT_MARIA_IMPORT_POLICY}" ]; then + args+=(--maria-import-policy "${INPUT_MARIA_IMPORT_POLICY}") fi - MARIA_REPOSITORY_ID_ARG="" - if [ -n "${{ inputs.maria-repository-id }}" ]; then - MARIA_REPOSITORY_ID_ARG="--maria-repository-id ${{ inputs.maria-repository-id }}" + if [ -n "${INPUT_MARIA_REPOSITORY_ID}" ]; then + args+=(--maria-repository-id "${INPUT_MARIA_REPOSITORY_ID}") fi - MARIA_PULL_REQUEST_ID_ARG="" - if [ -n "${{ inputs.maria-pull-request-id }}" ]; then - MARIA_PULL_REQUEST_ID_ARG="--maria-pull-request-id ${{ inputs.maria-pull-request-id }}" - elif [ -n "${{ github.event.pull_request.number }}" ]; then - MARIA_PULL_REQUEST_ID_ARG="--maria-pull-request-id ${{ github.event.pull_request.number }}" + if [ -n "${INPUT_MARIA_PULL_REQUEST_ID}" ]; then + args+=(--maria-pull-request-id "${INPUT_MARIA_PULL_REQUEST_ID}") + elif [ -n "${GITHUB_PULL_REQUEST_NUMBER}" ]; then + args+=(--maria-pull-request-id "${GITHUB_PULL_REQUEST_NUMBER}") fi - CHECKMARX_TENANT_ARG="" - if [ -n "${{ inputs.checkmarx-tenant }}" ]; then - CHECKMARX_TENANT_ARG="--checkmarx-tenant ${{ inputs.checkmarx-tenant }}" + if [ -n "${INPUT_CHECKMARX_TENANT}" ]; then + args+=(--checkmarx-tenant "${INPUT_CHECKMARX_TENANT}") fi set +e python3 -m secscore.cli.main pr \ - ${SARIF_ARG} \ - ${FINDINGS_ARG} \ - ${PROVIDER_ARG} \ + "${args[@]}" \ --policy "$POLICY_PATH" \ --out pr-comment.md \ - --json-out secscore-result.json \ - ${DIFF_ARG} \ - ${BASE_REF_ARG} \ - ${BRANCH_ARG} \ - ${CHECKMARX_PROJECT_ARG} \ - ${CHECKMARX_BASE_URL_ARG} \ - ${CHECKMARX_TOKEN_ARG} \ - ${CHECKMARX_TENANT_ARG} \ - ${TOKEN_ARG} \ - ${MARIA_URL_ARG} \ - ${MARIA_TOKEN_ARG} \ - ${MARIA_REPOSITORY_ID_ARG} \ - ${MARIA_PULL_REQUEST_ID_ARG} + --json-out secscore-result.json code=$? - echo "exit_code=$code" >> $GITHUB_OUTPUT + echo "exit_code=$code" >> "$GITHUB_OUTPUT" exit 0 - name: Read SecScore result @@ -233,10 +248,12 @@ runs: - name: Label PR based on SecScore if: ${{ always() }} uses: actions/github-script@v7 + env: + LABEL_REVIEW: ${{ inputs.label_review }} with: script: | const fs = require('fs'); - const label = '${{ inputs.label_review }}'; + const label = process.env.LABEL_REVIEW; if (!fs.existsSync('secscore-result.json')) return; @@ -270,10 +287,12 @@ runs: - name: Create SecScore status check if: ${{ always() }} uses: actions/github-script@v7 + env: + CHECK_NAME: ${{ inputs.check_name }} with: script: | const fs = require('fs'); - const checkName = '${{ inputs.check_name }}'; + const checkName = process.env.CHECK_NAME; if (!fs.existsSync('secscore-result.json')) { await github.rest.checks.create({ diff --git a/docs/ai-memory/reporting-config-ignored-in-pr-comment.md b/docs/ai-memory/reporting-config-ignored-in-pr-comment.md new file mode 100644 index 0000000..184d2fb --- /dev/null +++ b/docs/ai-memory/reporting-config-ignored-in-pr-comment.md @@ -0,0 +1,43 @@ +# Lição: o comentário de PR ignora `reporting` da policy (defaults 5/3 fixos) + +Resumo: [CORRIGIDO 2026-07-07] `render_pr_comment` não honrava `policy.reporting` +— usava sempre `max_findings=5` / `max_reasons=3`. Agora recebe `policy_config`. + +## Correção aplicada (2026-07-07) +`render_pr_comment(result, policy, policy_config=None)` passou a receber o dict +de policy real (via `main.py`), e `_get_reporting_config` lê dele +(`max_findings_in_comment`, `max_reasons`, `include_fields`). `include_fields` +agora efetivamente filtra localização e metadata em `_render_finding_line`. +Além disso, título/path/CVE (input não confiável do scanner) passam por +`_md_escape`/`_md_code`/`_url_encode_path` para evitar injeção de +link/emphasis/code no Markdown do comentário. Testes em `tests/test_reporting.py`. + +## Fato original confirmado (2026-07-07) +Em `secscore/core/reporting.py`, `_get_reporting_config(result)` lê +`getattr(result, "policy", None)`. Mas `EngineResult` +(`secscore/core/engine.py`) é um dataclass **sem** campo `policy`, então o +retorno é sempre `None` → cai nos defaults `DEFAULT_MAX_FINDINGS=5` / +`DEFAULT_MAX_REASONS=3`. + +Além disso, `render_pr_comment(result, policy=...)` é chamado em +`secscore/cli/main.py` passando `policy` como **string** (caminho do arquivo), +não o dict — então não há como o renderer ler a config mesmo que quisesse. + +## Impacto +- `policy-default.yml` define `max_findings_in_comment: 10`, mas o comentário + mostra no máximo 5 e adiciona "+N additional findings not shown." +- O parâmetro `include_fields` é buscado e repassado a `_render_finding_line`, + mas **nunca é usado** para filtrar campos (dead param). +- O engine (`_select_findings_to_show`, `_build_reasons`) JÁ respeita a policy; + a divergência é só na camada de renderização, que trunca de novo com defaults. + +## Por que importa +Usuários que ajustam `reporting.max_findings_in_comment` na policy esperam ver +o efeito no comentário de PR — hoje é silenciosamente ignorado. É um bug de +comportamento (não de segurança), baixa severidade, mas quebra a expectativa +"policy é a fonte de verdade". + +## Como corrigir (quando autorizado) +Passar o dict de policy real para `render_pr_comment` e ler `policy["reporting"]` +diretamente, OU adicionar `policy`/`reporting` ao `EngineResult`. Ajustar/─adicionar +teste em `tests/` cobrindo `max_findings_in_comment` != 5. diff --git a/pyproject.toml b/pyproject.toml index 08565ee..307aa99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ -[build-system] +[build-system] requires = ["setuptools>=61"] build-backend = "setuptools.build_meta" [project] name = "secscore" -version = "0.4.0" +version = "0.5.0" description = "Security score engine for CI/CD pipelines" requires-python = ">=3.9" authors = [{name="Cassio B. Pereira"}] @@ -15,5 +15,8 @@ dependencies = [ "rich>=13.0.0" ] +[project.scripts] +secscore = "secscore.cli.main:main" + [tool.setuptools.packages.find] include = ["secscore*"] diff --git a/secscore/__init__.py b/secscore/__init__.py index 6a9beea..3d18726 100644 --- a/secscore/__init__.py +++ b/secscore/__init__.py @@ -1 +1 @@ -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/secscore/adapters/maria_provider.py b/secscore/adapters/maria_provider.py index 9031a0b..21d3caa 100644 --- a/secscore/adapters/maria_provider.py +++ b/secscore/adapters/maria_provider.py @@ -1,9 +1,199 @@ from __future__ import annotations +import ipaddress +import os +import socket +from copy import deepcopy from typing import Any, Dict +from urllib.parse import urlsplit import requests +_PRIVATE_URL_OPT_IN = "SECSCORE_ALLOW_PRIVATE_MARIA_URLS" + + +def fetch_policy( + maria_url: str, + token: str, + repository_id: str, + timeout: int = 15, + policy_url: str | None = None, +) -> Dict[str, Any]: + if not maria_url: + raise ValueError("maria_url is required") + if not token: + raise ValueError("token is required") + if not repository_id: + raise ValueError("repository_id is required") + + resolved_policy_url = _validate_maria_url(policy_url or _derive_policy_url(maria_url)) + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "User-Agent": "SecScore/1.0 (+maria-integration)", + } + + response = requests.get( + resolved_policy_url, + params={"repositoryId": repository_id}, + headers=headers, + timeout=timeout, + ) + response.raise_for_status() + return response.json() + + +def build_secscore_policy_from_maria( + maria_policy: Dict[str, Any], + base_policy: Dict[str, Any], +) -> Dict[str, Any]: + policy = deepcopy(base_policy or {}) + + thresholds = maria_policy.get("thresholds") or {} + maria_scoring = maria_policy.get("scoring") or {} + penalties = maria_scoring.get("penalties") or {} + risk_weights = maria_scoring.get("risk_weights") or {} + application_context = maria_scoring.get("application_context") + risk_profile = maria_scoring.get("risk_profile") or {} + scoring_model = str(maria_scoring.get("model") or "").strip().lower() + + policy["policy_version"] = str(policy.get("policy_version") or "1.1") + policy["mode"] = "pull_request" + + decision = policy.get("decision") if isinstance(policy.get("decision"), dict) else {} + decision["pass_min_score"] = int(thresholds.get("pass_min", decision.get("pass_min_score", 85))) + decision["review_min_score"] = int(thresholds.get("review_min", decision.get("review_min_score", 51))) + decision["fail_below_score"] = int(thresholds.get("fail_min", decision.get("fail_below_score", 0))) + if scoring_model == "maria_riskscore_v1": + decision["model"] = "risk_score" + decision["pass_max_score"] = int(thresholds.get("pass_max_risk_score", decision.get("pass_max_score", 49))) + decision["review_max_score"] = int(thresholds.get("review_max_risk_score", decision.get("review_max_score", 79))) + decision["fail_min_risk_score"] = int(thresholds.get("fail_min_risk_score", decision.get("fail_min_risk_score", 80))) + policy["decision"] = decision + + scoring = policy.get("scoring") if isinstance(policy.get("scoring"), dict) else {} + scoring["base_score"] = int(maria_scoring.get("base_score", scoring.get("base_score", 100))) + if scoring_model == "maria_riskscore_v1": + scoring["model"] = scoring_model + scoring["risk_weights"] = { + "critical": int(risk_weights.get("critical", 10)), + "high": int(risk_weights.get("high", 5)), + "medium": int(risk_weights.get("medium", 2)), + "low": int(risk_weights.get("low", 1)), + "internet_exposure": int(risk_weights.get("internet_exposure", 12)), + "third_party_interaction": int(risk_weights.get("third_party_interaction", 8)), + "api_exposure": int(risk_weights.get("api_exposure", 6)), + "pii_data": int(risk_weights.get("pii_data", 10)), + "no_encryption": int(risk_weights.get("no_encryption", 8)), + "encryption_bonus": int(risk_weights.get("encryption_bonus", -4)), + "no_authentication": int(risk_weights.get("no_authentication", 8)), + "authentication_bonus": int(risk_weights.get("authentication_bonus", -3)), + "recent_commit": int(risk_weights.get("recent_commit", 2)), + "no_recent_commit": int(risk_weights.get("no_recent_commit", 5)), + } + scoring["application_context"] = application_context if isinstance(application_context, dict) else None + scoring["risk_profile"] = { + "enabled": bool(risk_profile.get("enabled", False)), + "business_multiplier": float(risk_profile.get("business_multiplier", 1.0)), + "data_multiplier": float(risk_profile.get("data_multiplier", 1.0)), + "combined_multiplier": float(risk_profile.get("combined_multiplier", 1.0)), + "max_combined_multiplier": float(risk_profile.get("max_combined_multiplier", 1.8)), + } + + scoring_penalties = scoring.get("penalties") if isinstance(scoring.get("penalties"), dict) else {} + scoring_penalties["critical"] = int(penalties.get("critical", scoring_penalties.get("critical", 40))) + scoring_penalties["high"] = int(penalties.get("high", scoring_penalties.get("high", 20))) + scoring_penalties["medium"] = int(penalties.get("medium", scoring_penalties.get("medium", 7))) + scoring_penalties["low"] = int(penalties.get("low", scoring_penalties.get("low", 2))) + scoring_penalties["info"] = int(scoring_penalties.get("info", 0)) + scoring["penalties"] = scoring_penalties + policy["scoring"] = scoring + + if bool(thresholds.get("hard_fail_on_critical", False)): + hard_fails = policy.get("hard_fails") + if not isinstance(hard_fails, list): + hard_fails = [] + + has_rule = any( + isinstance(rule, dict) and rule.get("id") == "MARIA_CRITICAL_NEW" + for rule in hard_fails + ) + if not has_rule: + hard_fails.append( + { + "id": "MARIA_CRITICAL_NEW", + "when": {"severity_in": ["critical"], "is_new": True}, + "reason": "M.A.R.I.A policy: new critical finding", + } + ) + policy["hard_fails"] = hard_fails + + return policy + + +def _derive_policy_url(maria_url: str) -> str: + cleaned = maria_url.strip().rstrip("/") + if cleaned.endswith("/submissions"): + return f"{cleaned[:-len('/submissions')]}/policy" + if cleaned.endswith("/policy"): + return cleaned + return f"{cleaned}/policy" + + +def _validate_maria_url(url: str) -> str: + parsed = urlsplit(url.strip()) + if parsed.scheme not in {"http", "https"}: + raise ValueError("M.A.R.I.A URL must use http or https") + if not parsed.hostname: + raise ValueError("M.A.R.I.A URL must include a hostname") + if parsed.username or parsed.password: + raise ValueError("M.A.R.I.A URL must not include embedded credentials") + + if _allow_private_maria_urls(): + return url.strip() + + hostname = parsed.hostname.strip().lower() + if hostname == "localhost" or hostname.endswith(".localhost"): + raise ValueError( + "M.A.R.I.A URL points to a local host. Set " + f"{_PRIVATE_URL_OPT_IN}=true only for trusted local development." + ) + + try: + addresses = [ipaddress.ip_address(hostname)] + except ValueError: + try: + addresses = [ + ipaddress.ip_address(info[4][0]) + for info in socket.getaddrinfo(hostname, parsed.port, proto=socket.IPPROTO_TCP) + ] + except socket.gaierror as exc: + raise ValueError(f"Could not resolve M.A.R.I.A URL host: {hostname}") from exc + + for address in addresses: + if _is_private_network_address(address): + raise ValueError( + "M.A.R.I.A URL resolves to a local, private, loopback, or reserved address. " + f"Set {_PRIVATE_URL_OPT_IN}=true only for trusted local development." + ) + + return url.strip() + + +def _allow_private_maria_urls() -> bool: + return os.getenv(_PRIVATE_URL_OPT_IN, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _is_private_network_address(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + return ( + address.is_private + or address.is_loopback + or address.is_link_local + or address.is_multicast + or address.is_reserved + or address.is_unspecified + ) + def send_submission( maria_url: str, @@ -23,7 +213,7 @@ def send_submission( } response = requests.post( - maria_url, + _validate_maria_url(maria_url), json=submission_payload, headers=headers, timeout=timeout, diff --git a/secscore/cli/main.py b/secscore/cli/main.py index e226b1f..c293b90 100644 --- a/secscore/cli/main.py +++ b/secscore/cli/main.py @@ -4,35 +4,90 @@ from __future__ import annotations import argparse -import json import copy +import json import os import subprocess from datetime import datetime, timezone from pathlib import Path +from urllib.parse import urlsplit, urlunsplit +import yaml from rich.console import Console +from rich.markdown import Markdown from rich.panel import Panel from rich.table import Table -from rich.markdown import Markdown - -import yaml from secscore.core.engine import EngineInput, run_engine -from secscore.core.reporting import render_pr_comment -from secscore.core.policy_validator import validate_policy, PolicyValidationError +from secscore.core.policy_validator import PolicyValidationError, validate_policy +from secscore.core.reporting import render_html_report, render_pr_comment EXIT = {"PASS": 0, "REVIEW": 1, "FAIL": 2} +def _is_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + +def _allowed_path_roots(include_ci_roots: bool = False) -> list[Path]: + roots = [Path.cwd().resolve()] + if include_ci_roots: + for env_name in ("GITHUB_WORKSPACE", "RUNNER_TEMP"): + value = os.getenv(env_name) + if value: + try: + roots.append(Path(value).expanduser().resolve()) + except Exception: + continue + return roots + + +def _resolve_cli_path( + value: str, + label: str, + *, + must_exist: bool = False, + for_output: bool = False, + include_ci_roots: bool = False, +) -> Path: + if not value or not str(value).strip(): + raise ValueError(f"{label} path is required") + + base_dir = Path.cwd().resolve() + raw_path = Path(str(value).strip()).expanduser() + candidate = raw_path if raw_path.is_absolute() else base_dir / raw_path + + if for_output: + resolved_parent = candidate.parent.resolve() + resolved = resolved_parent / candidate.name + else: + resolved = candidate.resolve(strict=must_exist) + + roots = _allowed_path_roots(include_ci_roots=include_ci_roots) + if not any(_is_within(resolved, root) or resolved == root for root in roots): + allowed = ", ".join(str(root) for root in roots) + raise ValueError(f"{label} path must resolve inside an allowed directory: {allowed}") + + if must_exist and not resolved.exists(): + raise FileNotFoundError(f"{label} file not found: {value}") + if for_output and not resolved.parent.exists(): + raise FileNotFoundError(f"{label} output directory not found: {resolved.parent}") + + return resolved + + def main() -> int: parser = argparse.ArgumentParser( prog="secscore", - description="SecScore - Security Score that matters." + description="SecScore - Security Score that matters.", ) sub = parser.add_subparsers(dest="cmd", required=True) - pr = sub.add_parser("pr", help="Run SecScore for Pull Requests (PR mode)") + pr = sub.add_parser("pr", help="Run SecScore for Pull Requests (PR mode)") pr.add_argument("--findings", help="Path to findings.json") pr.add_argument( @@ -47,11 +102,22 @@ def main() -> int: default=False, help="Disable diff-aware filtering (enabled by default in PR mode).", ) - pr.add_argument("--base-ref", dest="base_ref", default="origin/main", - help="Git ref to diff against (default: origin/main).") - pr.add_argument("--policy", required=True, help="Path to policy YAML") - pr.add_argument("--out", default="pr-comment.md", help="Output PR comment markdown") + pr.add_argument( + "--base-ref", + dest="base_ref", + default="origin/main", + help="Git ref to diff against (default: origin/main).", + ) + pr.add_argument("--policy", required=True, help="Path to policy YAML") + pr.add_argument("--out", default="pr-comment.md", help="Output PR comment markdown") pr.add_argument("--json-out", default="secscore-result.json", help="Output JSON result") + pr.add_argument( + "--html-output", + choices=["true", "false"], + default="false", + help="Set to true to generate a visual HTML report from the standard JSON result.", + ) + pr.add_argument("--html-out", default="secscore-report.html", help="Output HTML report") # provider pr.add_argument("--provider") @@ -60,13 +126,27 @@ def main() -> int: pr.add_argument("--checkmarx-token") pr.add_argument("--branch") pr.add_argument("--checkmarx-tenant") + + # M.A.R.I.A integration pr.add_argument( "--token", help="Generic bearer token accepted by integrations (used by M.A.R.I.A integration).", ) pr.add_argument("--maria-url", help="M.A.R.I.A API endpoint URL to receive consolidated findings.") pr.add_argument("--maria-token", help="M.A.R.I.A API bearer token (overrides --token).") - pr.add_argument("--maria-repository-id", help="M.A.R.I.A repository id (GUID). Required for submissions endpoint.") + pr.add_argument( + "--maria-import-policy", + choices=["true", "false"], + help=( + "Import risk policy from M.A.R.I.A on each run. " + "Defaults to true when integrated, false otherwise." + ), + ) + pr.add_argument("--maria-policy-url", help="Optional explicit M.A.R.I.A policy endpoint URL.") + pr.add_argument( + "--maria-repository-id", + help="M.A.R.I.A repository id (GUID). Required for submissions endpoint and policy import.", + ) pr.add_argument("--maria-submission-key", help="M.A.R.I.A submission key. Auto-generated when omitted.") pr.add_argument("--maria-commit-sha", help="Commit SHA for M.A.R.I.A submission.") pr.add_argument("--maria-branch-name", help="Branch name for M.A.R.I.A submission.") @@ -91,132 +171,227 @@ def main() -> int: args = parser.parse_args() - if args.cmd == "pr": - console = Console() - - # ---------------------------- - # Validate input mode - # ---------------------------- - if not args.sarif and not args.findings and not args.provider: - parser.error("You must provide one of: --sarif, --findings or --provider") - if args.maria_url and not (args.maria_token or args.token): - parser.error("When using --maria-url you must provide --maria-token or --token") - if args.maria_url and not args.maria_repository_id: - parser.error("When using --maria-url you must provide --maria-repository-id (GUID)") - - # ---------------------------- - # Load findings - # ---------------------------- - if args.sarif: - from secscore.normalizers.sarif import normalize_sarif - - sarif_paths = [] - for entry in args.sarif: - for p in entry.split(","): - p = p.strip() - if p: - sarif_paths.append(p) - - missing = [p for p in sarif_paths if not Path(p).exists()] - if missing: - for m in missing: - console.print(f"[bold red]SARIF file not found:[/bold red] {m}") - return 2 + if args.cmd != "pr": + return 3 + + console = Console() - if len(sarif_paths) == 1: - console.print(f"[cyan]Using SARIF input:[/cyan] {sarif_paths[0]}") - else: - console.print(f"[cyan]Using {len(sarif_paths)} SARIF files:[/cyan] {', '.join(sarif_paths)}") + # ---------------------------- + # Validate input mode + # ---------------------------- + if not args.sarif and not args.findings and not args.provider: + parser.error("You must provide one of: --sarif, --findings or --provider") - findings = normalize_sarif(sarif_paths) + maria_token = args.maria_token or args.token + is_maria_integrated = bool(args.maria_url and maria_token and args.maria_repository_id) - elif args.provider == "checkmarx": - from secscore.adapters.checkmarx_provider import fetch_findings - if not args.branch: - args.branch = "main" - console.print("[cyan]Fetching findings from Checkmarx API...[/cyan]") - findings = fetch_findings(args) + if args.maria_url and not maria_token: + parser.error("When using --maria-url you must provide --maria-token or --token") + if args.maria_url and not args.maria_repository_id: + parser.error("When using --maria-url you must provide --maria-repository-id (GUID)") + if args.maria_import_policy is None: + use_maria_policy = is_maria_integrated + else: + use_maria_policy = args.maria_import_policy == "true" + + if use_maria_policy and not is_maria_integrated: + parser.error( + "M.A.R.I.A policy import requires integration: --maria-url, " + "--maria-repository-id and --token/--maria-token" + ) + + # ---------------------------- + # Load findings + # ---------------------------- + input_mode = "unknown" + input_paths: list[str] = [] + if args.sarif: + from secscore.normalizers.sarif import normalize_sarif + + sarif_paths: list[str] = [] + for entry in args.sarif: + for part in entry.split(","): + path = part.strip() + if path: + try: + sarif_paths.append(str(_resolve_cli_path(path, "SARIF", must_exist=True))) + except Exception as exc: + console.print(f"[bold red]Invalid SARIF path:[/bold red] {path} ({exc})") + return 2 + + if len(sarif_paths) == 1: + console.print(f"[cyan]Using SARIF input:[/cyan] {sarif_paths[0]}") else: - findings_path = Path(args.findings) - if not findings_path.exists(): - raise FileNotFoundError(f"Findings file not found: {args.findings}") - console.print(f"[cyan]Using findings file:[/cyan] {args.findings}") - findings = json.loads(findings_path.read_text(encoding="utf-8")) - - # Preserve the consolidated payload before PR-specific filtering so - # M.A.R.I.A receives the full parser/normalizer output. - findings_for_maria = copy.deepcopy(findings) - - # ---------------------------- - # Diff-aware (v0.3.0: ativo por padrão) - # ---------------------------- - if not args.no_diff_aware: - findings = _apply_diff_aware(findings, args.base_ref, console) - else: - console.print("[yellow]Diff-aware filtering disabled.[/yellow]") + console.print(f"[cyan]Using {len(sarif_paths)} SARIF files:[/cyan] {', '.join(sarif_paths)}") + + input_mode = "sarif" + input_paths = sarif_paths + findings = normalize_sarif(sarif_paths) + + elif args.provider == "checkmarx": + from secscore.adapters.checkmarx_provider import fetch_findings + + if not args.branch: + args.branch = "main" + + console.print("[cyan]Fetching findings from Checkmarx API...[/cyan]") + input_mode = "provider" + input_paths = [args.provider] + findings = fetch_findings(args) + + else: + try: + findings_path = _resolve_cli_path(args.findings, "Findings", must_exist=True) + except Exception as exc: + console.print(f"[bold red]Invalid findings path:[/bold red] {args.findings} ({exc})") + return 2 + console.print(f"[cyan]Using findings file:[/cyan] {args.findings}") + input_mode = "findings" + input_paths = [str(findings_path)] + findings = json.loads(findings_path.read_text(encoding="utf-8")) + + # Preserve consolidated payload before PR-specific filtering so + # M.A.R.I.A receives full parser/normalizer output. + findings_for_maria = copy.deepcopy(findings) + + # ---------------------------- + # Diff-aware + # ---------------------------- + if not args.no_diff_aware: + findings = _apply_diff_aware(findings, args.base_ref, console) + else: + console.print("[yellow]Diff-aware filtering disabled.[/yellow]") - # ---------------------------- - # Load & validate policy - # ---------------------------- - policy = yaml.safe_load(Path(args.policy).read_text(encoding="utf-8")) + # ---------------------------- + # Load/Sync & validate policy + # ---------------------------- + try: + effective_policy_path = _resolve_cli_path(args.policy, "Policy", must_exist=True) + except Exception as exc: + console.print(f"[bold red]Invalid policy path:[/bold red] {args.policy} ({exc})") + return 2 + + try: + base_policy = yaml.safe_load(effective_policy_path.read_text(encoding="utf-8")) + except Exception as exc: + console.print(f"[bold red]Failed to load policy file:[/bold red] {args.policy} ({exc})") + return 2 + + policy = base_policy + if use_maria_policy: + from secscore.adapters.maria_provider import ( + build_secscore_policy_from_maria, + fetch_policy, + ) + console.print("[cyan]Syncing policy from M.A.R.I.A...[/cyan]") try: - validate_policy(policy) - except PolicyValidationError as exc: - console.print(f"[bold red]Erro na policy:[/bold red] {args.policy}") - for err in exc.errors: - console.print(f" [red]•[/red] {err}") + maria_policy = fetch_policy( + maria_url=args.maria_url, + token=maria_token, + repository_id=args.maria_repository_id, + timeout=args.maria_timeout, + policy_url=args.maria_policy_url, + ) + policy = build_secscore_policy_from_maria(maria_policy, base_policy) + + policy_dir = _resolve_cli_path("policy", "Policy output directory") + policy_dir.mkdir(parents=True, exist_ok=True) + effective_policy_path = policy_dir / "policy-maria.yml" + effective_policy_path.write_text( + yaml.safe_dump(policy, sort_keys=False), + encoding="utf-8", + ) + console.print(f"[green]M.A.R.I.A policy synced:[/green] {effective_policy_path}") + except Exception as exc: + console.print(f"[bold red]Failed to sync policy from M.A.R.I.A:[/bold red] {exc}") return 2 - # ---------------------------- - # Run engine - # ---------------------------- - result = run_engine(EngineInput(findings=findings, policy=policy, mode="pull_request")) - - # ---------------------------- - # Outputs - # ---------------------------- - md = render_pr_comment(result, policy=args.policy) - Path(args.out).write_text(md, encoding="utf-8") - - out_json = { - "score": result.score, - "decision": result.decision, - "reasons": result.reasons, - "hard_fails": [hf.__dict__ for hf in result.hard_fails], - "penalties_total": result.penalties_total, - "findings_new_count": len(result.findings_new), - "findings_shown_count": len(result.findings_shown), - } - Path(args.json_out).write_text(json.dumps(out_json, indent=2), encoding="utf-8") + try: + validate_policy(policy) + except PolicyValidationError as exc: + console.print(f"[bold red]Policy validation error:[/bold red] {effective_policy_path}") + for err in exc.errors: + console.print(f" [red]-[/red] {err}") + return 2 + + # ---------------------------- + # Run engine + # ---------------------------- + result = run_engine(EngineInput(findings=findings, policy=policy, mode="pull_request")) + + # ---------------------------- + # Outputs + # ---------------------------- + md = render_pr_comment(result, policy=str(effective_policy_path), policy_config=policy) + try: + output_path = _resolve_cli_path(args.out, "Markdown output", for_output=True) + json_output_path = _resolve_cli_path(args.json_out, "JSON output", for_output=True) + except Exception as exc: + console.print(f"[bold red]Invalid output path:[/bold red] {exc}") + return 2 + + output_path.write_text(md, encoding="utf-8") + + out_json = { + "score": result.score, + "decision": result.decision, + "reasons": result.reasons, + "hard_fails": [hf.__dict__ for hf in result.hard_fails], + "penalties_total": result.penalties_total, + "findings_new_count": len(result.findings_new), + "findings_shown_count": len(result.findings_shown), + "findings_shown": result.findings_shown, + } + json_output_path.write_text(json.dumps(out_json, indent=2), encoding="utf-8") - maria_token = args.maria_token or args.token - if args.maria_url and maria_token: - from secscore.adapters.maria_provider import send_submission + if args.html_output == "true": + try: + html_output_path = _resolve_cli_path(args.html_out, "HTML output", for_output=True) + except Exception as exc: + console.print(f"[bold red]Invalid HTML output path:[/bold red] {exc}") + return 2 - console.print(f"[cyan]Sending consolidated findings to M.A.R.I.A:[/cyan] {args.maria_url}") - try: - submission_payload = _build_maria_submission_payload(args, result, findings_for_maria) - send_submission( - maria_url=args.maria_url, - token=maria_token, - submission_payload=submission_payload, - timeout=args.maria_timeout, - ) - console.print("[green]M.A.R.I.A integration:[/green] findings sent successfully.") - except Exception as exc: - console.print(f"[bold yellow]Warning:[/bold yellow] failed to send findings to M.A.R.I.A ({exc})") - if args.maria_strict: - return 2 - elif maria_token and not args.maria_url: - console.print("[yellow]Warning:[/yellow] --token/--maria-token provided without --maria-url. Skipping M.A.R.I.A integration.") + execution_context = _build_execution_context( + args=args, + input_mode=input_mode, + input_paths=input_paths, + effective_policy_path=effective_policy_path, + use_maria_policy=use_maria_policy, + json_output_path=json_output_path, + html_output_path=html_output_path, + ) + html_report = render_html_report( + out_json, + json_path=str(json_output_path), + execution_context=execution_context, + ) + html_output_path.write_text(html_report, encoding="utf-8") + console.print(f"[green]HTML report generated:[/green] {html_output_path}") - render_terminal(result, md) + if args.maria_url and maria_token: + from secscore.adapters.maria_provider import send_submission - return EXIT[result.decision] + console.print(f"[cyan]Sending consolidated findings to M.A.R.I.A:[/cyan] {args.maria_url}") + try: + submission_payload = _build_maria_submission_payload(args, result, findings_for_maria) + send_submission( + maria_url=args.maria_url, + token=maria_token, + submission_payload=submission_payload, + timeout=args.maria_timeout, + ) + console.print("[green]M.A.R.I.A integration:[/green] findings sent successfully.") + except Exception as exc: + console.print(f"[bold yellow]Warning:[/bold yellow] failed to send findings to M.A.R.I.A ({exc})") + if args.maria_strict: + return 2 + elif maria_token and not args.maria_url: + console.print("[yellow]Warning:[/yellow] --token/--maria-token provided without --maria-url. Skipping M.A.R.I.A integration.") - return 3 + render_terminal(result, md) + return EXIT[result.decision] def _build_maria_submission_payload(args, result, findings_for_maria): @@ -271,6 +446,7 @@ def _build_maria_submission_payload(args, result, findings_for_maria): args.maria_cli_version, _read_secscore_version(), ) + findings_list = findings_for_maria.get("findings", []) if isinstance(findings_for_maria, dict) else findings_for_maria severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0} @@ -302,6 +478,69 @@ def _build_maria_submission_payload(args, result, findings_for_maria): } +def _build_execution_context( + args, + input_mode: str, + input_paths: list[str], + effective_policy_path: Path, + use_maria_policy: bool, + json_output_path: Path | None = None, + html_output_path: Path | None = None, +) -> dict: + context = { + "Command": "secscore pr", + "SecScore version": _read_secscore_version(), + "Input mode": input_mode, + "Input": input_paths, + "Input policy": str(args.policy), + "Effective policy": str(effective_policy_path), + "Diff-aware": not args.no_diff_aware, + "Base ref": args.base_ref, + "JSON output": str(json_output_path or args.json_out), + "HTML output": str(html_output_path or args.html_out), + } + + if args.provider: + context["Provider"] = args.provider + if args.branch: + context["Branch"] = args.branch + if args.checkmarx_project: + context["Checkmarx project"] = args.checkmarx_project + if args.checkmarx_base_url: + context["Checkmarx base URL"] = _safe_url_display(args.checkmarx_base_url) + if args.checkmarx_tenant: + context["Checkmarx tenant"] = args.checkmarx_tenant + + context["M.A.R.I.A integration"] = bool(args.maria_url) + if args.maria_url: + context["M.A.R.I.A URL"] = _safe_url_display(args.maria_url) + context["M.A.R.I.A policy import"] = use_maria_policy + if args.maria_repository_id: + context["M.A.R.I.A repository ID"] = args.maria_repository_id + if args.maria_pull_request_id: + context["Pull request ID"] = args.maria_pull_request_id + if args.maria_commit_sha: + context["Commit SHA"] = args.maria_commit_sha + if args.maria_pipeline_name: + context["Pipeline name"] = args.maria_pipeline_name + if args.maria_pipeline_run_id: + context["Pipeline run ID"] = args.maria_pipeline_run_id + + return context + + +def _safe_url_display(value: str) -> str: + try: + parts = urlsplit(value) + except Exception: + return value.split("?", 1)[0].split("#", 1)[0] + + if not parts.scheme or not parts.netloc: + return value.split("?", 1)[0].split("#", 1)[0] + + return urlunsplit((parts.scheme, parts.netloc, parts.path, "", "")) + + def _coalesce(*values): for value in values: if value is None: @@ -322,7 +561,13 @@ def _github_event_pull_request_number() -> str | None: return None try: - payload = json.loads(Path(event_path).read_text(encoding="utf-8")) + resolved_event_path = _resolve_cli_path( + event_path, + "GitHub event", + must_exist=True, + include_ci_roots=True, + ) + payload = json.loads(resolved_event_path.read_text(encoding="utf-8")) except Exception: return None @@ -351,6 +596,7 @@ def _git_value(args: list[str]) -> str | None: def _read_secscore_version() -> str | None: try: from secscore import __version__ # type: ignore[attr-defined] + return str(__version__) except Exception: return None @@ -358,35 +604,32 @@ def _read_secscore_version() -> str | None: def _apply_diff_aware(findings, base_ref: str, console) -> dict: """ - Aplica diff-aware filtering. - - Regras de segurança para não descartar findings por engano: - - Se git falhar (não é repo, sem remote): skip silencioso, findings intactos. - - Se changed_ranges vier vazio (branch sem commits, diff limpo): skip, - findings intactos. Um diff vazio não significa "nada mudou no PR" — - pode significar que o histórico não foi carregado (shallow clone). - - Só filtra quando changed_ranges tem ao menos um arquivo. + Apply diff-aware filtering safely. + + Safety rules: + - If git fails, skip filtering and keep findings. + - If changed ranges are empty, skip filtering and keep findings. + - Filter only when changed ranges contain at least one file. """ - from secscore.core.diff_filter import get_changed_ranges, filter_findings_by_diff + from secscore.core.diff_filter import filter_findings_by_diff, get_changed_ranges try: changed_ranges = get_changed_ranges(base_ref) except Exception as exc: console.print( - f"[yellow]Warning:[/yellow] diff-aware skipped — could not run git diff ({exc}). " + f"[yellow]Warning:[/yellow] diff-aware skipped - could not run git diff ({exc}). " "Use --no-diff-aware to suppress. All findings will be evaluated." ) return findings if not changed_ranges: console.print( - "[yellow]Warning:[/yellow] diff-aware skipped — no changed files detected " + "[yellow]Warning:[/yellow] diff-aware skipped - no changed files detected " f"against '{base_ref}'. All findings will be evaluated. " - "Tip: ensure fetch-depth: 0 in your checkout step, or use --no-diff-aware." + "Tip: ensure fetch-depth: 0 in checkout, or use --no-diff-aware." ) return findings - # Só filtra quando há dados confiáveis do diff console.print(f"[cyan]Diff-aware:[/cyan] {len(changed_ranges)} changed file(s) detected.") if isinstance(findings, dict): @@ -398,22 +641,24 @@ def _apply_diff_aware(findings, base_ref: str, console) -> dict: findings = filter_findings_by_diff(findings, changed_ranges) filtered = len(findings) - console.print(f"[cyan]Diff-aware:[/cyan] {original} findings → {filtered} after filter.") + console.print(f"[cyan]Diff-aware:[/cyan] {original} findings -> {filtered} after filter.") return findings def render_terminal(result, md): console = Console() - emoji = {"PASS": "✅", "REVIEW": "🟡", "FAIL": "⛔"}.get(result.decision, "❔") + emoji = {"PASS": "✅", "REVIEW": "🟡", "FAIL": "⛔"}.get(result.decision, "?") - console.print(Panel( - f"[bold]Decision:[/bold] {result.decision}\n" - f"[bold]Score:[/bold] {result.score}/100\n" - f"[bold]Findings (new):[/bold] {len(result.findings_new)}", - title=f"{emoji} SecScore Result", - border_style="cyan", - )) + console.print( + Panel( + f"[bold]Decision:[/bold] {result.decision}\n" + f"[bold]Score:[/bold] {result.score}/100\n" + f"[bold]Findings (new):[/bold] {len(result.findings_new)}", + title=f"{emoji} SecScore Result", + border_style="cyan", + ) + ) if result.findings_shown: table = Table(title="Findings introduced in this PR") @@ -423,22 +668,23 @@ def render_terminal(result, md): colors = { "CRITICAL": "[red]CRITICAL[/red]", - "HIGH": "[orange3]HIGH[/orange3]", - "MEDIUM": "[yellow]MEDIUM[/yellow]", - "LOW": "[green]LOW[/green]", + "HIGH": "[orange3]HIGH[/orange3]", + "MEDIUM": "[yellow]MEDIUM[/yellow]", + "LOW": "[green]LOW[/green]", } - for f in result.findings_shown[:5]: - sev = colors.get(f.get("severity", "").upper(), f.get("severity", "").upper()) - title = f.get("title", "") - path = f.get("asset", {}).get("path") - line = f.get("asset", {}).get("line") - loc = f"{path}:{line}" if path and line else (path or "") + for finding in result.findings_shown[:5]: + sev = colors.get(finding.get("severity", "").upper(), finding.get("severity", "").upper()) + title = finding.get("title", "") + path = finding.get("asset", {}).get("path") + line = finding.get("asset", {}).get("line") + loc = f"{path}:{line}" if path and line else (path or "") table.add_row(sev, title, loc) console.print(table) console.print(Panel(Markdown(md), title="PR Comment Preview", border_style="green")) + if __name__ == "__main__": raise SystemExit(main()) diff --git a/secscore/core/engine.py b/secscore/core/engine.py index 56bc959..b5528c0 100644 --- a/secscore/core/engine.py +++ b/secscore/core/engine.py @@ -5,8 +5,8 @@ from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple -import json import fnmatch +import math Decision = str # "PASS" | "REVIEW" | "FAIL" @@ -71,9 +71,7 @@ def run_engine(inp: EngineInput) -> EngineResult: hard_fails = _evaluate_hard_fails(findings_new, policy) # 4) Score - base_score = int(policy["scoring"]["base_score"]) - penalties_total = sum(_score_finding(f, policy) for f in findings_new) - score = max(0, int(round(base_score - penalties_total))) + score, penalties_total = _compute_score(findings_new, policy) # 5) Decisão decision = _decide(score, hard_fails, policy) @@ -203,6 +201,93 @@ def _score_finding(f: Dict[str, Any], policy: Dict[str, Any]) -> float: return base * conf_mult * fix_mult +def _compute_score(findings_new: List[Dict[str, Any]], policy: Dict[str, Any]) -> Tuple[int, float]: + scoring = policy.get("scoring") or {} + model = str(scoring.get("model") or "").strip().lower() + + if model == "maria_riskscore_v1": + return _compute_maria_risk_score(findings_new, policy) + + base_score = int(scoring["base_score"]) + penalties_total = sum(_score_finding(f, policy) for f in findings_new) + score = max(0, int(round(base_score - penalties_total))) + return score, float(penalties_total) + + +def _compute_maria_risk_score( + findings_new: List[Dict[str, Any]], + policy: Dict[str, Any], +) -> Tuple[int, float]: + scoring = policy.get("scoring") or {} + weights = scoring.get("risk_weights") or {} + context = scoring.get("application_context") + risk_profile = scoring.get("risk_profile") or {} + + sev_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0} + for finding in findings_new: + sev = str(finding.get("severity", "")).strip().lower() + if sev in sev_counts: + sev_counts[sev] += 1 + + raw = ( + sev_counts["critical"] * int(weights.get("critical", 10)) + + sev_counts["high"] * int(weights.get("high", 5)) + + sev_counts["medium"] * int(weights.get("medium", 2)) + + sev_counts["low"] * int(weights.get("low", 1)) + ) + + context_adjustment = _compute_maria_context_adjustment(context, weights) + base_score = _clamp_int(raw + context_adjustment, 0, 100) + + combined_multiplier = 1.0 + if bool(risk_profile.get("enabled", False)): + combined_multiplier = float(risk_profile.get("combined_multiplier", 1.0)) + + # Match C# MidpointRounding.AwayFromZero semantics for non-negative values. + final_score = _clamp_int(_round_away_from_zero(base_score * combined_multiplier), 0, 100) + return final_score, float(raw + context_adjustment) + + +def _compute_maria_context_adjustment( + context: Optional[Dict[str, Any]], + weights: Dict[str, Any], +) -> int: + if not isinstance(context, dict): + return 0 + + adjustment = 0 + if bool(context.get("is_internet_exposed", False)): + adjustment += int(weights.get("internet_exposure", 12)) + if bool(context.get("has_third_party_interaction", False)): + adjustment += int(weights.get("third_party_interaction", 8)) + if bool(context.get("has_apis", False)): + adjustment += int(weights.get("api_exposure", 6)) + if bool(context.get("handles_pii", False)): + adjustment += int(weights.get("pii_data", 10)) + + has_encrypted_data = bool(context.get("has_encrypted_data", False)) + adjustment += int(weights.get("encryption_bonus", -4) if has_encrypted_data else weights.get("no_encryption", 8)) + + requires_authentication = bool(context.get("requires_authentication", False)) + adjustment += int( + weights.get("authentication_bonus", -3) + if requires_authentication + else weights.get("no_authentication", 8) + ) + + return adjustment + + +def _round_away_from_zero(value: float) -> int: + if value >= 0: + return int(math.floor(value + 0.5)) + return int(math.ceil(value - 0.5)) + + +def _clamp_int(value: int, low: int, high: int) -> int: + return max(low, min(high, int(value))) + + def _decide( score: int, hard_fails: List[HardFailHit], @@ -212,6 +297,16 @@ def _decide( return "FAIL" d = policy["decision"] + decision_model = str(d.get("model") or "").strip().lower() + + if decision_model == "risk_score": + pass_max_score = int(d.get("pass_max_score", 49)) + review_max_score = int(d.get("review_max_score", 79)) + if score <= pass_max_score: + return "PASS" + if score <= review_max_score: + return "REVIEW" + return "FAIL" if score >= int(d["pass_min_score"]): return "PASS" @@ -307,4 +402,4 @@ def should_ignore_path(path: str, patterns: List[str]) -> bool: if pattern.endswith("/") and pattern in path: return True - return False \ No newline at end of file + return False diff --git a/secscore/core/reporting.py b/secscore/core/reporting.py index 584b86e..c5238f7 100644 --- a/secscore/core/reporting.py +++ b/secscore/core/reporting.py @@ -1,6 +1,11 @@ from __future__ import annotations +import html +import json +import re +from datetime import datetime from typing import Any, Dict, List, Optional +from urllib.parse import quote from .engine import EngineResult @@ -8,6 +13,30 @@ DEFAULT_MAX_FINDINGS = 5 DEFAULT_MAX_REASONS = 3 +# Markdown-significant characters that must be neutralized when injecting +# untrusted scanner text (titles, paths, CVEs) into the PR comment, so a +# crafted finding cannot inject links, emphasis, code spans or inline HTML. +_MD_ESCAPE_RE = re.compile(r"([\\`*_\[\]<>|])") + + +def _md_escape(text: Any) -> str: + """Backslash-escape Markdown control characters in untrusted inline text.""" + return _MD_ESCAPE_RE.sub(r"\\\1", str(text)) + + +def _md_code(text: Any) -> str: + """Sanitize text for use inside a code span (backticks cannot be escaped).""" + return str(text).replace("`", "'") + + +def _url_encode_path(path: Any) -> str: + """Percent-encode a path for a Markdown link target, keeping path separators. + + ``#`` is intentionally NOT in the safe set so a literal ``#`` in a filename + is encoded instead of being parsed as the start of a URL fragment. + """ + return quote(str(path), safe="/") + def _get(d: Dict[str, Any], path: str) -> Optional[Any]: cur: Any = d @@ -18,14 +47,19 @@ def _get(d: Dict[str, Any], path: str) -> Optional[Any]: return cur -def _get_reporting_config(result: EngineResult): +def _field_enabled(include_fields: Optional[List[str]], name: str) -> bool: + """A field is rendered when no include_fields filter is set, or it is listed.""" + return include_fields is None or name in include_fields - reporting = {} - policy = getattr(result, "policy", None) +def _get_reporting_config(policy_config: Optional[Dict[str, Any]]): - if policy: - reporting = policy.get("reporting", {}) + reporting = {} + + if isinstance(policy_config, dict): + candidate = policy_config.get("reporting") + if isinstance(candidate, dict): + reporting = candidate max_findings = reporting.get("max_findings_in_comment", DEFAULT_MAX_FINDINGS) max_reasons = reporting.get("max_reasons", DEFAULT_MAX_REASONS) @@ -43,7 +77,7 @@ def format_decision(decision: str): return styles.get(decision, decision) -def render_pr_comment(result: EngineResult, policy) -> str: +def render_pr_comment(result: EngineResult, policy, policy_config=None) -> str: emoji = { "PASS": "✅", @@ -53,7 +87,7 @@ def render_pr_comment(result: EngineResult, policy) -> str: decision_fmt = f"**{result.decision}**" - max_findings, max_reasons, include_fields = _get_reporting_config(result) + max_findings, max_reasons, include_fields = _get_reporting_config(policy_config) lines: List[str] = [] @@ -115,33 +149,36 @@ def render_pr_comment(result: EngineResult, policy) -> str: def _render_finding_line(f: Dict[str, Any], include_fields: Optional[List[str]]) -> str: - title = f.get("title", "Untitled") - sev = f.get("severity", "info").upper() + title = _md_escape(f.get("title", "Untitled")) + sev = str(f.get("severity", "info")).upper() path = _get(f, "asset.path") line = _get(f, "asset.line") loc = "" - if path and line: - loc = f"[`{path}:{line}`](./{path}#L{line})" - elif path: - loc = f"`{path}`" + if _field_enabled(include_fields, "asset.path") and path: + show_line = _field_enabled(include_fields, "asset.line") and line + if show_line: + href = f"./{_url_encode_path(path)}#L{line}" + loc = f"[`{_md_code(f'{path}:{line}')}`]({href})" + else: + loc = f"`{_md_code(path)}`" extras = [] - cve = _get(f, "metadata.cve") - pkg = _get(f, "metadata.package") - img = _get(f, "metadata.image") + cve = _get(f, "metadata.cve") if _field_enabled(include_fields, "metadata.cve") else None + pkg = _get(f, "metadata.package") if _field_enabled(include_fields, "metadata.package") else None + img = _get(f, "metadata.image") if _field_enabled(include_fields, "metadata.image") else None if cve: - extras.append(str(cve)) + extras.append(_md_escape(cve)) if pkg: - extras.append(str(pkg)) + extras.append(_md_escape(pkg)) if img: - extras.append(str(img)) + extras.append(_md_escape(img)) extra_s = f" [{', '.join(extras)}]" if extras else "" @@ -223,4 +260,519 @@ def summarize_findings(findings): parts.append(f"{count} {label}") - return parts \ No newline at end of file + return parts + + +def render_html_report( + result_json: Dict[str, Any], + json_path: str = "secscore-result.json", + execution_context: Optional[Dict[str, Any]] = None, +) -> str: + """Render a self-contained HTML report from the standard JSON output.""" + + data = json.dumps(result_json, ensure_ascii=False) + escaped_json = ( + data.replace("&", "\\u0026") + .replace("<", "\\u003c") + .replace(">", "\\u003e") + ) + escaped_path = html.escape(json_path) + escaped_href = html.escape(json_path, quote=True) + generated_at = datetime.now().strftime("%d/%m/%Y %H:%M:%S") + context_rows = _render_html_context_rows(execution_context or {}) + + return f""" + + + + + SecScore Report + + + +
+
+
+ + +
+
+
+
+

SecScore Report

+

Visual summary generated from the standard SecScore JSON output.

+
+

+ Source JSON
+ {escaped_path}
+ +

+
+ +
+
+
+
+ 0 + security score +
+
+
+ UNKNOWN +
+
New findings0
+
Shown findings0
+
Penalty total0
+
+

+
+
+ +
+

Decision Drivers

+
+
+ +
+

Severity Mix

+
+
+ +
+

Hard Fails

+
+
+ +
+

Findings

+
+
+ +
+ Execution Parameters + + + {context_rows} + +
+
+
+
+ + + + + + +""" + + +def _render_html_context_rows(execution_context: Dict[str, Any]) -> str: + linkable_keys = {"Input policy", "Effective policy", "JSON output", "HTML output"} + rows = [] + for key, value in execution_context.items(): + if value is None: + continue + if isinstance(value, bool): + display_value = "enabled" if value else "disabled" + elif isinstance(value, (list, tuple)): + display_value = ", ".join(str(item) for item in value if item is not None) + else: + display_value = str(value) + + if not display_value: + continue + + escaped_key = html.escape(str(key)) + if str(key) in linkable_keys: + href = display_value.replace("\\", "/") + escaped_href = html.escape(href, quote=True) + value_html = f'{html.escape(display_value)}' + else: + value_html = html.escape(display_value) + + rows.append("" f"{escaped_key}" f"{value_html}" "") + + if not rows: + return 'No execution parameters were provided.' + + return "\n".join(rows) diff --git a/secscore/normalizers/sarif.py b/secscore/normalizers/sarif.py index f206863..79c9f56 100644 --- a/secscore/normalizers/sarif.py +++ b/secscore/normalizers/sarif.py @@ -10,6 +10,20 @@ from typing import List, Union +def _resolve_sarif_path(path: str) -> Path: + base_dir = Path.cwd().resolve() + raw_path = Path(str(path).strip()).expanduser() + candidate = raw_path if raw_path.is_absolute() else base_dir / raw_path + resolved = candidate.resolve(strict=True) + + try: + resolved.relative_to(base_dir) + except ValueError as exc: + raise ValueError(f"SARIF path must resolve inside the current workspace: {base_dir}") from exc + + return resolved + + def normalize_sarif(path: Union[str, List[str]]) -> dict: """ Aceita um único path (str) ou uma lista de paths (List[str]). @@ -41,7 +55,8 @@ def normalize_sarif(path: Union[str, List[str]]) -> dict: def _parse_single(path: str) -> list: """Parseia um único arquivo SARIF e retorna lista de findings.""" - data = json.loads(Path(path).read_text(encoding="utf-8")) + sarif_path = _resolve_sarif_path(path) + data = json.loads(sarif_path.read_text(encoding="utf-8")) findings = [] for run in data.get("runs", []): @@ -106,4 +121,4 @@ def map_severity(level: str, props_severity: str = "") -> str: if normalized_level == "note": return "low" - return "low" \ No newline at end of file + return "low" diff --git a/tests/test_maria_integration.py b/tests/test_maria_integration.py index d75d178..7d56915 100644 --- a/tests/test_maria_integration.py +++ b/tests/test_maria_integration.py @@ -2,6 +2,7 @@ import sys import unittest +import json from argparse import Namespace from pathlib import Path from types import SimpleNamespace @@ -9,7 +10,10 @@ import requests -from secscore.cli.main import _build_maria_submission_payload, main +from secscore.cli.main import _build_maria_submission_payload, _resolve_cli_path, main +from secscore.adapters.maria_provider import build_secscore_policy_from_maria +from secscore.adapters.maria_provider import _validate_maria_url +from secscore.core.engine import EngineInput, run_engine ROOT = Path(__file__).resolve().parents[1] @@ -52,6 +56,26 @@ def test_build_payload_includes_reported_score_decision_and_summary_counts(self) self.assertEqual(payload["Findings"], [{"id": "x"}]) +class SecurityHardeningTests(unittest.TestCase): + def test_cli_paths_must_stay_inside_workspace(self): + outside = ROOT.parent / "outside.json" + + with self.assertRaises(ValueError): + _resolve_cli_path(str(outside), "Test path") + + def test_maria_url_blocks_localhost_by_default(self): + with patch.dict("os.environ", {}, clear=True): + with self.assertRaises(ValueError): + _validate_maria_url("http://localhost:5213/api/secscore/submissions") + + def test_maria_url_allows_localhost_with_explicit_opt_in(self): + with patch.dict("os.environ", {"SECSCORE_ALLOW_PRIVATE_MARIA_URLS": "true"}): + self.assertEqual( + _validate_maria_url("http://localhost:5213/api/secscore/submissions"), + "http://localhost:5213/api/secscore/submissions", + ) + + class MariaStrictModeTests(unittest.TestCase): def _run_main_with_http_error(self, status_code: int) -> int: argv = [ @@ -68,6 +92,8 @@ def _run_main_with_http_error(self, status_code: int) -> int: "f427f613-de06-43f6-aec0-a8dfe7b227a5", "--token", "maria_ss_test_test", + "--maria-import-policy", + "false", "--maria-strict", "--out", "tmp-pr.md", @@ -94,6 +120,217 @@ def test_maria_strict_returns_fail_on_403(self): self.assertEqual(self._run_main_with_http_error(403), 2) +class HtmlOutputTests(unittest.TestCase): + def test_html_output_generates_report_from_standard_json(self): + json_out = Path("tmp-html-result.json") + html_out = Path("tmp-secscore-report.html") + for path in [json_out, html_out]: + if path.exists(): + path.unlink() + + argv = [ + "secscore", + "pr", + "--sarif", + str(SARIF_REVIEW), + "--policy", + str(POLICY_PR), + "--no-diff-aware", + "--out", + "tmp-html-pr.md", + "--json-out", + str(json_out), + "--html-output", + "true", + "--html-out", + str(html_out), + ] + with ( + patch.object(sys, "argv", argv), + patch("secscore.cli.main.render_terminal", return_value=None), + ): + main() + + self.assertTrue(json_out.exists()) + self.assertTrue(html_out.exists()) + + data = json.loads(json_out.read_text(encoding="utf-8")) + html = html_out.read_text(encoding="utf-8") + self.assertEqual(data["decision"], "REVIEW") + self.assertIn("SecScore Report", html) + self.assertIn(str(json_out), html) + self.assertIn('"decision": "REVIEW"', html) + self.assertIn("Execution Parameters", html) + self.assertIn("secscore pr", html) + self.assertIn("SecScore version", html) + self.assertIn(str(POLICY_PR), html) + self.assertIn("Diff-aware", html) + self.assertIn("Copy JSON path", html) + self.assertIn("riskSummary", html) + self.assertIn("#L", html) + + +class MariaPolicyImportTests(unittest.TestCase): + def test_build_secscore_policy_from_maria_overrides_risk_scoring_thresholds(self): + base_policy = { + "policy_version": "1.1", + "mode": "pull_request", + "decision": {"pass_min_score": 80, "review_min_score": 51, "fail_below_score": 50}, + "scoring": { + "base_score": 100, + "penalties": {"critical": 40, "high": 20, "medium": 7, "low": 2, "info": 0}, + "multipliers": {"confidence": {"high": 1.0}}, + }, + "hard_fails": [], + } + maria_policy = { + "thresholds": { + "pass_min": 90, + "review_min": 70, + "fail_min": 0, + "hard_fail_on_critical": True, + }, + "scoring": { + "base_score": 120, + "penalties": {"critical": 50, "high": 25, "medium": 9, "low": 3}, + }, + } + + merged = build_secscore_policy_from_maria(maria_policy, base_policy) + + self.assertEqual(merged["decision"]["pass_min_score"], 90) + self.assertEqual(merged["decision"]["review_min_score"], 70) + self.assertEqual(merged["scoring"]["base_score"], 120) + self.assertEqual(merged["scoring"]["penalties"]["critical"], 50) + self.assertEqual(merged["scoring"]["penalties"]["high"], 25) + self.assertEqual(merged["scoring"]["penalties"]["medium"], 9) + self.assertEqual(merged["scoring"]["penalties"]["low"], 3) + self.assertEqual(merged["scoring"]["penalties"]["info"], 0) + self.assertTrue(any(hf.get("id") == "MARIA_CRITICAL_NEW" for hf in merged["hard_fails"])) + + def test_build_secscore_policy_from_maria_enables_risk_score_model(self): + base_policy = { + "decision": {"pass_min_score": 85, "review_min_score": 51}, + "scoring": {"base_score": 100, "penalties": {"critical": 40, "high": 20, "medium": 7, "low": 2, "info": 0}}, + "hard_fails": [], + } + maria_policy = { + "thresholds": { + "pass_min": 85, + "review_min": 51, + "fail_min": 0, + "pass_max_risk_score": 49, + "review_max_risk_score": 79, + "fail_min_risk_score": 80, + }, + "scoring": { + "model": "maria_riskscore_v1", + "risk_weights": { + "critical": 10, + "high": 5, + "medium": 2, + "low": 1, + "internet_exposure": 12, + "third_party_interaction": 8, + "api_exposure": 6, + "pii_data": 10, + "no_encryption": 8, + "encryption_bonus": -4, + "no_authentication": 8, + "authentication_bonus": -3, + }, + "application_context": { + "is_internet_exposed": True, + "has_third_party_interaction": True, + "has_apis": True, + "handles_pii": True, + "has_encrypted_data": True, + "requires_authentication": True, + }, + "risk_profile": { + "enabled": True, + "business_multiplier": 1.25, + "data_multiplier": 1.15, + "combined_multiplier": 1.4375, + }, + "base_score": 100, + "penalties": {"critical": 40, "high": 20, "medium": 7, "low": 2}, + }, + } + + merged = build_secscore_policy_from_maria(maria_policy, base_policy) + + self.assertEqual(merged["decision"]["model"], "risk_score") + self.assertEqual(merged["decision"]["pass_max_score"], 49) + self.assertEqual(merged["decision"]["review_max_score"], 79) + self.assertEqual(merged["scoring"]["model"], "maria_riskscore_v1") + self.assertTrue(merged["scoring"]["risk_profile"]["enabled"]) + + +class MariaRiskScoreEngineTests(unittest.TestCase): + def test_engine_matches_maria_risk_formula_and_decision(self): + policy = { + "decision": { + "model": "risk_score", + "pass_min_score": 85, + "review_min_score": 51, + "pass_max_score": 49, + "review_max_score": 79, + }, + "scoring": { + "model": "maria_riskscore_v1", + "base_score": 100, + "penalties": {"critical": 40, "high": 20, "medium": 7, "low": 2, "info": 0}, + "risk_weights": { + "critical": 10, + "high": 5, + "medium": 2, + "low": 1, + "internet_exposure": 12, + "third_party_interaction": 8, + "api_exposure": 6, + "pii_data": 10, + "no_encryption": 8, + "encryption_bonus": -4, + "no_authentication": 8, + "authentication_bonus": -3, + }, + "application_context": { + "is_internet_exposed": True, + "has_third_party_interaction": True, + "has_apis": True, + "handles_pii": True, + "has_encrypted_data": True, + "requires_authentication": True, + }, + "risk_profile": { + "enabled": True, + "combined_multiplier": 1.4375, + }, + }, + "hard_fails": [], + "ignore_paths": [], + "suppressions": {}, + "reporting": {}, + } + findings = { + "findings": [ + {"severity": "critical", "is_new": True, "asset": {"path": "src/a.py"}}, + {"severity": "high", "is_new": True, "asset": {"path": "src/b.py"}}, + {"severity": "high", "is_new": True, "asset": {"path": "src/c.py"}}, + {"severity": "medium", "is_new": True, "asset": {"path": "src/d.py"}}, + ] + } + + result = run_engine(EngineInput(findings=findings, policy=policy)) + + # raw = (1*10)+(2*5)+(1*2)=22 + # context = 12+8+6+10-4-3 = 29 + # base = clamp(22+29)=51 + # final = round_away(51*1.4375)=73 + self.assertEqual(result.score, 73) + self.assertEqual(result.decision, "REVIEW") + + if __name__ == "__main__": unittest.main() - diff --git a/tests/test_reporting.py b/tests/test_reporting.py new file mode 100644 index 0000000..8233e9f --- /dev/null +++ b/tests/test_reporting.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace + +from secscore.core.reporting import render_pr_comment + + +def _result(findings_shown, reasons=None, decision="REVIEW", score=70): + return SimpleNamespace( + decision=decision, + score=score, + findings_shown=list(findings_shown), + reasons=list(reasons or []), + ) + + +def _finding(title="Issue", severity="high", path=None, line=None, **metadata): + f = {"title": title, "severity": severity} + if path is not None: + f["asset"] = {"path": path, "line": line} + if metadata: + f["metadata"] = metadata + return f + + +class ReportingConfigTests(unittest.TestCase): + def test_policy_max_findings_in_comment_is_honored(self): + findings = [_finding(title=f"F{i}", severity="high") for i in range(4)] + result = _result(findings) + policy_config = {"reporting": {"max_findings_in_comment": 2}} + + md = render_pr_comment(result, policy="p.yml", policy_config=policy_config) + + self.assertIn("F0", md) + self.assertIn("F1", md) + self.assertNotIn("F3", md) + self.assertIn("_+2 additional findings not shown._", md) + + def test_policy_max_reasons_is_honored(self): + result = _result([_finding()], reasons=["r1", "r2", "r3"]) + policy_config = {"reporting": {"max_reasons": 1}} + + md = render_pr_comment(result, policy="p.yml", policy_config=policy_config) + + self.assertIn("- r1", md) + self.assertNotIn("- r2", md) + self.assertIn("_+2 more reasons not shown._", md) + + def test_defaults_apply_without_policy_config(self): + findings = [_finding(title=f"F{i}") for i in range(7)] + result = _result(findings) + + md = render_pr_comment(result, policy="p.yml") + + # DEFAULT_MAX_FINDINGS == 5 -> 2 remaining not shown. + self.assertIn("_+2 additional findings not shown._", md) + + def test_include_fields_excludes_metadata_and_location(self): + result = _result( + [_finding(path="src/a.py", line=10, cve="CVE-2026-0001", package="left-pad")] + ) + policy_config = {"reporting": {"include_fields": ["title", "severity"]}} + + md = render_pr_comment(result, policy="p.yml", policy_config=policy_config) + + self.assertNotIn("CVE-2026-0001", md) + self.assertNotIn("src/a.py", md) + + +class ReportingMarkdownHardeningTests(unittest.TestCase): + def test_title_markdown_link_injection_is_neutralized(self): + malicious = "Click [here](http://evil.example) and `code`" + result = _result([_finding(title=malicious)]) + + md = render_pr_comment(result, policy="p.yml") + + # The raw link/code syntax must not survive unescaped. + self.assertNotIn("[here](http://evil.example)", md) + self.assertIn(r"\[here\]", md) + self.assertNotIn("and `code`", md) + + def test_location_path_is_percent_encoded_in_link(self): + result = _result([_finding(path="src/weird (v2).py", line=5)]) + + md = render_pr_comment(result, policy="p.yml") + + # Parentheses in the href would otherwise break the Markdown link. + self.assertIn("./src/weird%20%28v2%29.py#L5", md) + + +if __name__ == "__main__": + unittest.main() diff --git a/tmp-result.json b/tmp-result.json deleted file mode 100644 index a1fc5fa..0000000 --- a/tmp-result.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "score": 84, - "decision": "REVIEW", - "reasons": [ - "Potential path traversal risk", - "SQL query built using user input", - "Unvalidated redirect" - ], - "hard_fails": [], - "penalties_total": 15.68, - "findings_new_count": 4, - "findings_shown_count": 4 -} \ No newline at end of file