diff --git a/plugins/magpie-security-tools/tools/asf-security-dash/README.md b/plugins/magpie-security-tools/tools/asf-security-dash/README.md index 23a573e2f..d1eef550f 100644 --- a/plugins/magpie-security-tools/tools/asf-security-dash/README.md +++ b/plugins/magpie-security-tools/tools/asf-security-dash/README.md @@ -70,15 +70,18 @@ A project without a tracker that wants Magpie to keep per-report state pairs it ## Invocation ```bash -uv run --project tools/asf-security-dash magpie-asf-security-dash cases --pmc -uv run --project tools/asf-security-dash magpie-asf-security-dash reconcile --pmc --tracker issues.json +uv run --project tools/asf-security-dash magpie-asf-security-dash cases --pmc --project-md /project.md +uv run --project tools/asf-security-dash magpie-asf-security-dash reconcile --pmc --project-md /project.md --tracker issues.json # Either command reads saved API output instead of fetching it: -uv run --project tools/asf-security-dash magpie-asf-security-dash cases --pmc --input reports.json +uv run --project tools/asf-security-dash magpie-asf-security-dash cases --pmc --project-md /project.md --input reports.json ``` `--pmc` is the PMC id as the dashboard spells it (`project_url` and the API path use it as is). +`--project-md` is the project's resolved `project.md`: the dashboard covers ASF projects only, +so the bridge refuses, before it reads a session or sends a request, unless that file declares `organization: ASF`. Exit codes: `0` success, `1` an error (the message names a missing or expired session, -a PMC the user may not see, or a malformed response), `2` a usage error. +a PMC the user may not see, or a malformed response), `2` a usage error, +`3` the project is not an ASF project. ## Output contract diff --git a/plugins/magpie-security-tools/tools/asf-security-dash/src/magpie_asf_security_dash/__init__.py b/plugins/magpie-security-tools/tools/asf-security-dash/src/magpie_asf_security_dash/__init__.py index 56c4e65a8..b14d7bfc2 100644 --- a/plugins/magpie-security-tools/tools/asf-security-dash/src/magpie_asf_security_dash/__init__.py +++ b/plugins/magpie-security-tools/tools/asf-security-dash/src/magpie_asf_security_dash/__init__.py @@ -29,10 +29,13 @@ def main(argv: Sequence[str] | None = None) -> int: """CLI entry point.""" from magpie_asf_security_dash.cli import main as cli_main - from magpie_asf_security_dash.client import DashError + from magpie_asf_security_dash.client import DashError, NotAsf try: return cli_main(argv) + except NotAsf as exc: + print(f"magpie-asf-security-dash: {exc}", file=sys.stderr) + return 3 except DashError as exc: print(f"magpie-asf-security-dash error: {exc}", file=sys.stderr) return 1 diff --git a/plugins/magpie-security-tools/tools/asf-security-dash/src/magpie_asf_security_dash/cli.py b/plugins/magpie-security-tools/tools/asf-security-dash/src/magpie_asf_security_dash/cli.py index 7b4527635..da210964a 100644 --- a/plugins/magpie-security-tools/tools/asf-security-dash/src/magpie_asf_security_dash/cli.py +++ b/plugins/magpie-security-tools/tools/asf-security-dash/src/magpie_asf_security_dash/cli.py @@ -27,12 +27,31 @@ from typing import Any from magpie_asf_security_dash import cases -from magpie_asf_security_dash.client import DashError, check_pmc, fetch_reports, load_cookie, parse_reports +from magpie_asf_security_dash.client import ( + DashError, + NotAsf, + check_pmc, + fetch_reports, + load_cookie, + parse_reports, +) +from magpie_asf_security_dash.organization import read_organization def main(argv: Sequence[str] | None = None) -> int: """Run the magpie-asf-security-dash command line interface.""" args = _build_parser().parse_args(argv) + # dash.security.apache.org lists ASF PMCs only: nothing is read or sent + # for a project that does not declare `organization: ASF`. + try: + org = read_organization(args.project_md) + except OSError as exc: + raise DashError(f"cannot read {args.project_md}: {exc}") from exc + if org != "ASF": + raise NotAsf( + f"{args.project_md} declares organization {org!r}; the ASF Security dashboard " + "covers ASF projects only (organization: ASF)" + ) pmc = check_pmc(args.pmc) raw = parse_reports(args.input.read_bytes()) if args.input else fetch_reports(pmc, load_cookie()) found = cases.normalise(raw) @@ -70,6 +89,13 @@ def _build_parser() -> argparse.ArgumentParser: command.add_argument( "--pmc", required=True, help="PMC id, as the dashboard spells it (e.g. airflow)." ) + command.add_argument( + "--project-md", + type=Path, + required=True, + help="The project's resolved /project.md; the bridge runs only " + "when it declares organization: ASF.", + ) command.add_argument( "--input", type=Path, diff --git a/plugins/magpie-security-tools/tools/asf-security-dash/src/magpie_asf_security_dash/client.py b/plugins/magpie-security-tools/tools/asf-security-dash/src/magpie_asf_security_dash/client.py index 0af52aeeb..775cb4054 100644 --- a/plugins/magpie-security-tools/tools/asf-security-dash/src/magpie_asf_security_dash/client.py +++ b/plugins/magpie-security-tools/tools/asf-security-dash/src/magpie_asf_security_dash/client.py @@ -46,6 +46,10 @@ class DashError(Exception): """Raised when the bridge cannot complete a request.""" +class NotAsf(DashError): + """The project is not an ASF project, so the dashboard has nothing for it.""" + + class SessionExpired(DashError): """The session cookie is missing, rejected, or has expired.""" diff --git a/plugins/magpie-security-tools/tools/asf-security-dash/src/magpie_asf_security_dash/organization.py b/plugins/magpie-security-tools/tools/asf-security-dash/src/magpie_asf_security_dash/organization.py new file mode 100644 index 000000000..d80c2bac6 --- /dev/null +++ b/plugins/magpie-security-tools/tools/asf-security-dash/src/magpie_asf_security_dash/organization.py @@ -0,0 +1,73 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""The project's organization, read from its `project.md`. + +The dashboard lists ASF PMCs only, so the bridge runs only for a project +whose `project.md` declares `organization: ASF`. The reading mirrors +`release_config.mdconfig.organization` (a `| organization | ... |` row, +else an `organization:` line; `independent` when neither is set), kept +here so the bridge depends on nothing else in the framework. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +DEFAULT = "independent" +_FENCE = re.compile(r"^\s*(```|~~~)") +# A value is a backticked token (an annotation may follow it, as in +# `ASF` (derived: apache/ GitHub org)) or, unquoted, the whole cell / rest of +# the line: never a prefix, so `ASF.example` is not `ASF`. +_ROW = re.compile(r"^\s*\|\s*`?organization`?\s*\|([^|]*)(?:\||$)") +_LINE = re.compile(r"^\s*organization:(.*)$") +_QUOTED = re.compile(r"^`([^`]*)`") + + +def _value(raw: str) -> str: + raw = raw.strip() + quoted = _QUOTED.match(raw) + return (quoted.group(1) if quoted else raw).strip() + + +_COMMENT = re.compile(r"", re.DOTALL) + + +def organization(text: str) -> str: + """`project.md` → `organization`; `independent` when unset or still a TODO.""" + inside = False + line_value = None + # A commented-out row is not configuration (release_config strips them too). + for line in _COMMENT.sub("", text).splitlines(): + if _FENCE.match(line): + inside = not inside + continue + if inside or line.lstrip().startswith(">"): + continue + row = _ROW.match(line) + if row: + value = _value(row.group(1)) + return DEFAULT if not value or value.startswith("TODO") else value + if line_value is None and (match := _LINE.match(line)) and (value := _value(match.group(1))): + line_value = value + return line_value or DEFAULT + + +def read_organization(path: Path) -> str: + """The organization `path` declares; raises `OSError` when it cannot be read.""" + return organization(path.read_text(encoding="utf-8")) diff --git a/plugins/magpie-security/skills/issue-sync/SKILL.md b/plugins/magpie-security/skills/issue-sync/SKILL.md index ddcede7bb..fde950251 100644 --- a/plugins/magpie-security/skills/issue-sync/SKILL.md +++ b/plugins/magpie-security/skills/issue-sync/SKILL.md @@ -24,9 +24,9 @@ when_to_use: | the tracker with dash.security.apache.org. argument-hint: "[issue-number]" capability: capability:intake -surface_hash: sha256:019c545c3013d4c2 +surface_hash: sha256:5d8871fa61317a06 license: Apache-2.0 -measured_tokens: 7058 +measured_tokens: 7105 --- ", re.DOTALL) + + +def organization(text: str) -> str: + """`project.md` → `organization`; `independent` when unset or still a TODO.""" + inside = False + line_value = None + # A commented-out row is not configuration (release_config strips them too). + for line in _COMMENT.sub("", text).splitlines(): + if _FENCE.match(line): + inside = not inside + continue + if inside or line.lstrip().startswith(">"): + continue + row = _ROW.match(line) + if row: + value = _value(row.group(1)) + return DEFAULT if not value or value.startswith("TODO") else value + if line_value is None and (match := _LINE.match(line)) and (value := _value(match.group(1))): + line_value = value + return line_value or DEFAULT + + +def read_organization(path: Path) -> str: + """The organization `path` declares; raises `OSError` when it cannot be read.""" + return organization(path.read_text(encoding="utf-8")) diff --git a/tools/asf-security-dash/tests/test_asf_security_dash.py b/tools/asf-security-dash/tests/test_asf_security_dash.py index 26965d4d6..795ea3a64 100644 --- a/tools/asf-security-dash/tests/test_asf_security_dash.py +++ b/tools/asf-security-dash/tests/test_asf_security_dash.py @@ -331,27 +331,123 @@ def test_the_session_path_follows_the_environment(monkeypatch: pytest.MonkeyPatc # --- cli --------------------------------------------------------------------------- -def test_cases_from_saved_input(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: +@pytest.fixture +def asf_project(tmp_path: Path) -> str: + """A project.md that declares the ASF, as every bridge run needs.""" + path = tmp_path / "project.md" + path.write_text("| Key | Value |\n|---|---|\n| `organization` | `ASF` |\n") + return str(path) + + +def test_cases_from_saved_input(tmp_path: Path, capsys: pytest.CaptureFixture[str], asf_project: str) -> None: saved = tmp_path / "reports.json" saved.write_text(json.dumps([case("a@x")])) - assert package_main(["cases", "--pmc", "foo", "--input", str(saved)]) == 0 + assert package_main(["cases", "--pmc", "foo", "--project-md", asf_project, "--input", str(saved)]) == 0 out = json.loads(capsys.readouterr().out) assert out["pmc"] == "foo" and out["cases"][0]["message_id"] == "a@x" -def test_reconcile_from_saved_input(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: +def test_reconcile_from_saved_input( + tmp_path: Path, capsys: pytest.CaptureFixture[str], asf_project: str +) -> None: saved = tmp_path / "reports.json" saved.write_text(json.dumps([case("a@x")])) tracker = tmp_path / "issues.json" tracker.write_text(json.dumps([issue("1", ["a@x"], decision="accepted")])) - assert package_main(["reconcile", "--pmc", "foo", "--input", str(saved), "--tracker", str(tracker)]) == 0 + assert ( + package_main( + [ + "reconcile", + "--pmc", + "foo", + "--project-md", + asf_project, + "--input", + str(saved), + "--tracker", + str(tracker), + ] + ) + == 0 + ) assert kinds(json.loads(capsys.readouterr().out)) == ["triage-on-dashboard"] -def test_errors_exit_1_with_a_message(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: +def test_errors_exit_1_with_a_message( + tmp_path: Path, capsys: pytest.CaptureFixture[str], asf_project: str +) -> None: tracker = tmp_path / "issues.json" tracker.write_text("{}") saved = tmp_path / "reports.json" saved.write_text("[]") - assert package_main(["reconcile", "--pmc", "foo", "--input", str(saved), "--tracker", str(tracker)]) == 1 + assert ( + package_main( + [ + "reconcile", + "--pmc", + "foo", + "--project-md", + asf_project, + "--input", + str(saved), + "--tracker", + str(tracker), + ] + ) + == 1 + ) assert "JSON list" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "text", + [ + "| `organization` | `independent` |\n", + "| `organization` | TODO: the organization whose defaults this project inherits |\n", + "organization: acme\n", + "no organization at all\n", + "```\norganization: ASF\n```\n", + "\n| `organization` | `independent` |\n", + "\n", + "| `organization` | `ASF.example` |\n", + "| `organization` | ASFX |\n", + "organization: ASF-fork\n", + "organization: ASF extra\n", + ], +) +def test_a_project_that_is_not_asf_is_refused_before_anything_is_read( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, text: str +) -> None: + """The dashboard lists ASF PMCs only: no cookie read, no request, exit 3.""" + project = tmp_path / "project.md" + project.write_text(text) + + def boom(*a: object, **kw: object) -> None: + raise AssertionError("must not touch the session or the network") + + monkeypatch.setattr("magpie_asf_security_dash.cli.load_cookie", boom) + monkeypatch.setattr("magpie_asf_security_dash.cli.fetch_reports", boom) + assert package_main(["cases", "--pmc", "foo", "--project-md", str(project)]) == 3 + assert "ASF projects only" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "text", + [ + "| `organization` | `ASF` |\n", + "| `organization` | `ASF` (derived: apache/ GitHub org) |\n", + "organization: ASF\n", + "organization: `ASF`\n", + "organization: `ASF` (from .asf.yaml)\n", + ], +) +def test_an_asf_project_md_is_recognised(text: str) -> None: + from magpie_asf_security_dash.organization import organization + + assert organization(text) == "ASF" + + +def test_the_project_md_flag_is_required() -> None: + with pytest.raises(SystemExit) as excinfo: + package_main(["cases", "--pmc", "foo"]) + assert excinfo.value.code == 2 diff --git a/tools/skill-evals/README.md b/tools/skill-evals/README.md index d33c34ed6..4354b4944 100644 --- a/tools/skill-evals/README.md +++ b/tools/skill-evals/README.md @@ -21,7 +21,7 @@ Suites are currently implemented for: - **security-issue-triage** — 19 cases across 6 steps (2, 2.5, 2.6, 3, 4, 6); Steps 1, 5 and 7 and the Step 6 bare-name scrub are decided and pytest-tested in `tools/security-management` - **security-issue-deduplicate** — 3 cases across 1 step (3, the merge inputs); Steps 1, 2, 3 (the merged body), 4, 5 and 6 are decided and pytest-tested in `tools/security-management` - **security-cve-allocate** — 3 cases across 1 step (4, the reporter draft); Steps 1, 2, 3, 4 (proposal and rollup), 5 and 7 are decided and pytest-tested in `tools/security-management` -- **security-issue-sync**: 41 cases across 8 steps (1d, 1f, 2a, 2b, 2c, 5b judged gates, handoff-delegate, D4 dashboard proposals); bulk selection and pre-flight, the buckets, Steps 3 and 6, the Guardrails check, the security CC, the drafting backend, the GHSA tier and the Step 5b flow are decided and pytest-tested in `tools/security-management` +- **security-issue-sync**: 44 cases across 9 steps (1d, 1f, 2a, 2b, 2c, 5b judged gates, handoff-delegate, D4 dashboard proposals); bulk selection and pre-flight, the buckets, Steps 3 and 6, the Guardrails check, the security CC, the drafting backend, the GHSA tier and the Step 5b flow are decided and pytest-tested in `tools/security-management` - **security-issue-fix** — 31 cases across 8 steps (2, 3, 5b, 5d, 5g, 7, and Subversion steps 8 and 9); the flow choice, 5a, 5c, 5e, 5f, 6 and 11 are decided and pytest-tested in `tools/security-management` - **security-issue-invalidate** — 7 cases across 2 steps (3, 4); Steps 2, 5a, 5b, 5d, 5e, 5f and 7 are decided and pytest-tested in `tools/security-management` - **security-issue-import-from-md** — 7 cases across 4 steps (1, 2, 4, 6); the Step 1 parser and Step 2 keyword clean are pytest-tested in `tools/security-management` diff --git a/tools/skill-evals/evals/security-issue-sync/README.md b/tools/skill-evals/evals/security-issue-sync/README.md index 003bfbd87..9d785606b 100644 --- a/tools/skill-evals/evals/security-issue-sync/README.md +++ b/tools/skill-evals/evals/security-issue-sync/README.md @@ -16,6 +16,7 @@ Steps 0 (pre-flight), 1a–1c and 1e (data gathering), 1g (cve.org API check), 4 | 2c | Next-step recommendation | 4 | Steps 3, 6, 11, 13 | | 5b | The four judged pre-push gates | 2 | A private-scanner analyst credit fails the anonymise gate; a clean fix-released record passes all four | | 1d | Actionable signals in comments and mail | 6 | Affected-versions lower bound (widen / keep + backtick-wrap), title strip and its under-3-words guard, private-scanner credit (anonymise / public-credit exemption), reporter CVSS surfaced only | +| D0 | Dashboard mode is ASF-only | 3 | An ASF project proceeds; an `independent` project and an unset (`TODO`) organization stop without asking for a dashboard session | | D4 | Dashboard-mode proposals | 3 | A dashboard-triage finding beside an untracked report; two findings for the ASF Security team; an untagged case that may be a standing label | | Hand-off delegate | Next-step hand-off honouring a project delegate (pre-flight `step-13`) | 4 | Triage delegated (`replace`) named and invoked through `security-issue-triage`, CVE allocation delegated (`after`), a delegate for an unrelated skill (not named), and `--no-overrides` (ignored) | diff --git a/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/assertions.json b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/assertions.json new file mode 100644 index 000000000..dba8ad180 --- /dev/null +++ b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/assertions.json @@ -0,0 +1,7 @@ +{ + "never_asks_for_a_session_when_stopped": { + "field": "asks_for_dashboard_session", + "type": "regex", + "pattern": "^(false|False)$" + } +} diff --git a/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-1-asf-project/expected.json b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-1-asf-project/expected.json new file mode 100644 index 000000000..553cf1037 --- /dev/null +++ b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-1-asf-project/expected.json @@ -0,0 +1,5 @@ +{ + "proceeds_to_dashboard": true, + "asks_for_dashboard_session": false, + "offers_regular_sync": false +} diff --git a/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-1-asf-project/report.md b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-1-asf-project/report.md new file mode 100644 index 000000000..82b525115 --- /dev/null +++ b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-1-asf-project/report.md @@ -0,0 +1,9 @@ + + +## project.md + +| Key | Value | +|---|---| +| `project` | `airflow` | +| `organization` | `ASF` | diff --git a/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-2-independent-project/expected.json b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-2-independent-project/expected.json new file mode 100644 index 000000000..21b3a41ad --- /dev/null +++ b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-2-independent-project/expected.json @@ -0,0 +1,5 @@ +{ + "proceeds_to_dashboard": false, + "asks_for_dashboard_session": false, + "offers_regular_sync": true +} diff --git a/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-2-independent-project/report.md b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-2-independent-project/report.md new file mode 100644 index 000000000..138b4db12 --- /dev/null +++ b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-2-independent-project/report.md @@ -0,0 +1,9 @@ + + +## project.md + +| Key | Value | +|---|---| +| `project` | `widget` | +| `organization` | `independent` | diff --git a/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-3-todo-organization/expected.json b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-3-todo-organization/expected.json new file mode 100644 index 000000000..21b3a41ad --- /dev/null +++ b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-3-todo-organization/expected.json @@ -0,0 +1,5 @@ +{ + "proceeds_to_dashboard": false, + "asks_for_dashboard_session": false, + "offers_regular_sync": true +} diff --git a/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-3-todo-organization/report.md b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-3-todo-organization/report.md new file mode 100644 index 000000000..240860ad4 --- /dev/null +++ b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/case-3-todo-organization/report.md @@ -0,0 +1,9 @@ + + +## project.md + +| Key | Value | +|---|---| +| `project` | `widget` | +| `organization` | TODO: the organization whose defaults this project inherits | diff --git a/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/output-spec.md b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/output-spec.md new file mode 100644 index 000000000..ca2486ec6 --- /dev/null +++ b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/output-spec.md @@ -0,0 +1,19 @@ + + +# Output specification — Step D0 ASF-only gate + +The user asked to reconcile the tracker with the security dashboard. Read the project's `project.md` excerpt and decide whether dashboard mode proceeds. +Return a JSON object with these fields only: + +```json +{ + "proceeds_to_dashboard": , + "asks_for_dashboard_session": , + "offers_regular_sync": +} +``` + +Rules: +- Only `organization: ASF` proceeds; `independent`, any other organization, unset or a `TODO` stops the mode. +- A stopped mode never asks for a dashboard session. diff --git a/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/step-config.json b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/step-config.json new file mode 100644 index 000000000..c5accf4f5 --- /dev/null +++ b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/step-config.json @@ -0,0 +1,4 @@ +{ + "skill_md": "skills/security-issue-sync/dashboard.md", + "step_heading": "## Step D0 \u2014 ASF projects only" +} diff --git a/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/user-prompt-template.md b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/user-prompt-template.md new file mode 100644 index 000000000..46a3d6c6b --- /dev/null +++ b/tools/skill-evals/evals/security-issue-sync/step-d0-asf-only/fixtures/user-prompt-template.md @@ -0,0 +1,6 @@ + + +{report} + +Return JSON only.