diff --git a/AGENTS.md b/AGENTS.md index 714f8da..a05c8b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -716,6 +716,19 @@ Get specific analysis by ID. ### DELETE `/api/analyses/{id}` Delete analysis by ID. +### POST `/api/analyses/{id}/dismissals` +Dismiss a finding (`{"fingerprint": "...", "reason": "..."}`) for the analysis's repository or action. Returns the analysis with dismissals applied. + +### DELETE `/api/analyses/{id}/dismissals/{fingerprint}` +Restore a dismissed finding. Returns the analysis with dismissals applied. + +### GET `/api/dismissals?target=owner/repo` +List dismissals for a repository or action. + +## Dismissed Findings + +`backend/dismissals.py` gives every issue a `fingerprint`: a hash of its node id, type, identifying fields and message, leaving out what shifts between runs (`line_number`, `latest_version`, `days_old`, `commit_date`, digits in the message). Dismissals are stored per audit target in `data/dismissals.json` and applied when an audit is built and whenever a stored analysis is read, so one dismissal covers every run of that target. A dismissed issue carries `"dismissed": {"reason", "dismissed_at"}` and is left out of `issue_count`, node `severity` and the statistics (`dismissed_issues` counts them). If you add a volatile field to an issue, add it to `_VOLATILE_FIELDS` or dismissals of that finding won't survive a re-run. + ## Configuration ### Trusted Action Publishers diff --git a/backend/dismissals.py b/backend/dismissals.py new file mode 100644 index 0000000..f84d8d1 --- /dev/null +++ b/backend/dismissals.py @@ -0,0 +1,144 @@ +"""Dismissed findings: stable issue fingerprints and a per-target store. + +A dismissal is recorded against an audit target (``owner/repo`` or an action +reference) and an issue fingerprint, so it carries over to later audits of the +same target. The fingerprint identifies a finding by the node it sits on, its +type, its identifying fields and its message, and leaves out what shifts +between runs without changing the finding: line numbers, the latest available +version, commit ages, and numbers in the message. +""" +import datetime +import hashlib +import json +import re +import threading +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +# Fields that change between runs (or carry presentation only) and so must not +# change a finding's identity. +_VOLATILE_FIELDS = { + "severity", "message", "evidence", "recommendation", "line_number", + "latest_version", "days_old", "commit_date", + "versions", "version_count", "workflows", "workflow_count", + # Set by this module. + "fingerprint", "dismissed", +} + +# Node types a finding is mirrored onto from the node it was raised on, so +# the graph is navigable (see main._add_package_dependency_nodes). The last two +# are the names analyses stored by older versions use for image nodes. +MIRROR_NODE_TYPES = {"package", "image", "container_image", "docker_image"} + +_MAX_REASON_LENGTH = 500 + + +def _normalized_message(issue: Dict[str, Any]) -> str: + message = str(issue.get("message", "")) + for field in ("latest_version", "days_old", "commit_date", "line_number"): + value = issue.get(field) + if value not in (None, ""): + message = message.replace(str(value), f"<{field}>") + return re.sub(r"\d+", "#", message) + + +def _content_key(issue: Dict[str, Any]) -> str: + identity = {k: v for k, v in issue.items() if k not in _VOLATILE_FIELDS} + return json.dumps([identity, _normalized_message(issue)], sort_keys=True, default=str) + + +def fingerprint(node_id: str, issue: Dict[str, Any]) -> str: + """Stable identifier for a finding on a node.""" + digest = hashlib.sha256(f"{node_id}\n{_content_key(issue)}".encode()).hexdigest() + return digest[:20] + + +def apply_dismissals(nodes: Iterable[Dict[str, Any]], dismissed: Dict[str, Dict[str, Any]]) -> None: + """Tag every issue with its fingerprint and, when dismissed, the dismissal. + + Mutates the issues in place. A finding mirrored onto a package or image + node shares the fingerprint of the finding it mirrors, so dismissing one + dismisses both. + """ + nodes = list(nodes) + source_fingerprints: Dict[str, str] = {} + ordered = sorted(nodes, key=lambda n: n.get("type") in MIRROR_NODE_TYPES) + for node in ordered: + mirror = node.get("type") in MIRROR_NODE_TYPES + for issue in node.get("issues", []): + key = _content_key(issue) + fp = source_fingerprints.get(key) if mirror else None + if fp is None: + fp = fingerprint(node["id"], issue) + if not mirror: + source_fingerprints.setdefault(key, fp) + issue["fingerprint"] = fp + record = dismissed.get(fp) + if record: + issue["dismissed"] = {k: record.get(k) for k in ("reason", "dismissed_at")} + else: + issue.pop("dismissed", None) + + +def is_dismissed(issue: Dict[str, Any]) -> bool: + return bool(issue.get("dismissed")) + + +class DismissalStore: + """Dismissals keyed by audit target, persisted as one JSON file.""" + + def __init__(self, path: Optional[str] = None): + self.path = Path(path) if path else Path(__file__).parent.parent / "data" / "dismissals.json" + self.path.parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.Lock() + + def _read(self) -> Dict[str, Dict[str, Dict[str, Any]]]: + if not self.path.exists(): + return {} + with open(self.path, "r") as f: + return json.load(f) + + def _write(self, data: Dict[str, Dict[str, Dict[str, Any]]]) -> None: + tmp_path = self.path.with_suffix(".tmp") + try: + with open(tmp_path, "w") as f: + json.dump(data, f, indent=2) + tmp_path.replace(self.path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + + def get(self, target: Optional[str]) -> Dict[str, Dict[str, Any]]: + """Dismissals for a target, keyed by fingerprint.""" + if not target: + return {} + with self._lock: + return self._read().get(target, {}) + + def list(self, target: str) -> List[Dict[str, Any]]: + return [{"fingerprint": fp, **record} for fp, record in self.get(target).items()] + + def add(self, target: str, fp: str, issue: Dict[str, Any], node_id: str, reason: str = "") -> Dict[str, Any]: + record = { + "reason": reason.strip()[:_MAX_REASON_LENGTH], + "dismissed_at": datetime.datetime.now(datetime.UTC).isoformat(), + "type": issue.get("type"), + "node": node_id, + "message": issue.get("message"), + } + with self._lock: + data = self._read() + data.setdefault(target, {})[fp] = record + self._write(data) + return record + + def remove(self, target: str, fp: str) -> bool: + with self._lock: + data = self._read() + if fp not in data.get(target, {}): + return False + del data[target][fp] + if not data[target]: + del data[target] + self._write(data) + return True diff --git a/backend/graph_builder.py b/backend/graph_builder.py index cfcaffd..3eedb82 100644 --- a/backend/graph_builder.py +++ b/backend/graph_builder.py @@ -12,6 +12,19 @@ def __init__(self): self.issues: Dict[str, List[Dict[str, Any]]] = defaultdict(list) self._edge_keys: Set[Tuple[str, str]] = set() + @classmethod + def from_graph_data(cls, graph_data: Dict[str, Any]) -> "GraphBuilder": + """Rebuild a builder from get_graph_data() output, e.g. a stored analysis.""" + graph = cls() + for node in graph_data.get("nodes", []): + graph.nodes[node["id"]] = node + node.setdefault("issues", []) + if node["issues"]: + graph.issues[node["id"]] = node["issues"] + for edge in graph_data.get("edges", []): + graph.add_edge(edge["source"], edge["target"], edge.get("type", "uses")) + return graph + def add_node(self, node_id: str, label: str, node_type: str = "action", metadata: Optional[Dict] = None): """Add a node to the graph.""" if node_id not in self.nodes: @@ -116,7 +129,7 @@ def get_graph_data(self) -> Dict[str, Any]: """Get graph data in format suitable for visualization.""" depths = self._compute_depths() for node_id, node in self.nodes.items(): - issues = node.get("issues", []) + issues = [i for i in node.get("issues", []) if not i.get("dismissed")] node["issue_count"] = len(issues) node["depth"] = depths.get(node_id, 0) severities = {issue.get("severity", "low") for issue in issues} @@ -129,24 +142,28 @@ def get_graph_data(self) -> Dict[str, Any]: } def _unique_issues(self) -> List[Dict[str, Any]]: - """All issues, counting an issue object once even if attached to several nodes. + """All issues, counting an issue once even if attached to several nodes. Findings are deliberately mirrored onto related nodes (a package-install finding onto the package node, an unpinned-image finding onto the image - node) so the graph is navigable; they are still one finding. + node) so the graph is navigable; they are still one finding. Mirrors + share one object in a fresh audit and one fingerprint once dismissals + are applied (a stored analysis has lost the shared objects). """ - seen: Set[int] = set() + seen: Set[Any] = set() unique = [] for issues in self.issues.values(): for issue in issues: - if id(issue) not in seen: - seen.add(id(issue)) + key = issue.get("fingerprint") or id(issue) + if key not in seen: + seen.add(key) unique.append(issue) return unique def get_statistics(self) -> Dict[str, Any]: """Get statistics about the graph.""" - unique_issues = self._unique_issues() + all_issues = self._unique_issues() + unique_issues = [i for i in all_issues if not i.get("dismissed")] severity_counts = defaultdict(int) for issue in unique_issues: severity_counts[issue.get("severity", "low")] += 1 @@ -157,6 +174,9 @@ def get_statistics(self) -> Dict[str, Any]: "total_edges": len(self.edges), "total_issues": len(unique_issues), "severity_counts": dict(severity_counts), - "nodes_with_issues": sum(1 for issues in self.issues.values() if issues), + "dismissed_issues": len(all_issues) - len(unique_issues), + "nodes_with_issues": sum( + 1 for issues in self.issues.values() if any(not i.get("dismissed") for i in issues) + ), "max_depth": max(depths.values(), default=0), } diff --git a/backend/main.py b/backend/main.py index a8c5d50..897d9f2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -20,6 +20,7 @@ from graph_builder import GraphBuilder from repo_cloner import RepoCloner, CloneError from analysis_storage import AnalysisStorage +from dismissals import DismissalStore, apply_dismissals import pin_client app = FastAPI( @@ -48,6 +49,7 @@ auditor = SecurityAuditor() cloner = RepoCloner() storage = AnalysisStorage() +dismissals = DismissalStore() logger = logging.getLogger(__name__) # Serve frontend static files if they exist (for production builds) @@ -72,6 +74,11 @@ class AuditRequest(BaseModel): use_clone: bool = False # New option to use clone instead of API +class DismissRequest(BaseModel): + fingerprint: str + reason: str = "" + + class AuditYAMLRequest(BaseModel): yaml_content: str github_token: Optional[str] = None @@ -605,6 +612,7 @@ async def _run_audit(request: AuditRequest, log_fn: Optional[Callable[[str], Non if log_fn: log_fn("Building final graph...") + apply_dismissals(graph.nodes.values(), dismissals.get(repository or action)) graph_data = graph.get_graph_data() statistics = graph.get_statistics() @@ -615,7 +623,13 @@ async def _run_audit(request: AuditRequest, log_fn: Optional[Callable[[str], Non statistics=statistics, method="clone" if request.use_clone else "api" ) - return {"id": analysis_id, "graph": graph_data, "statistics": statistics} + return { + "id": analysis_id, + "repository": repository, + "action": action, + "graph": graph_data, + "statistics": statistics, + } finally: await _close_client(client) @@ -1312,6 +1326,9 @@ async def _audit_inline_workflow(request: AuditYAMLRequest, workflow: Dict[str, _add_workflow_container_image_nodes(graph, workflow, workflow_node_id, workflow_issues) + # An inline workflow has no target to remember dismissals against; this + # only gives its findings fingerprints. + apply_dismissals(graph.nodes.values(), {}) graph_data = graph.get_graph_data() statistics = graph.get_statistics() @@ -1359,15 +1376,63 @@ async def list_analyses(repository: Optional[str] = None, limit: int = 50): return storage.list_analyses(limit=limit, repository=repository) -@app.get("/api/analyses/{analysis_id}") -async def get_analysis(analysis_id: str): - """Get a specific analysis by ID.""" +def _analysis_target(analysis: Dict[str, Any]) -> Optional[str]: + return analysis.get("repository") or analysis.get("action") + + +def _load_analysis(analysis_id: str) -> Dict[str, Any]: + """A stored analysis with the target's current dismissals applied. + + Dismissals live apart from analyses, so one made on any run of a target + shows on every run of it, including ones stored before it was made. + """ analysis = storage.get_analysis(analysis_id) if not analysis: raise HTTPException(status_code=404, detail="Analysis not found") + graph = GraphBuilder.from_graph_data(analysis.get("graph", {})) + apply_dismissals(graph.nodes.values(), dismissals.get(_analysis_target(analysis))) + analysis["graph"] = graph.get_graph_data() + analysis["statistics"] = graph.get_statistics() return analysis +@app.get("/api/analyses/{analysis_id}") +async def get_analysis(analysis_id: str): + """Get a specific analysis by ID.""" + return _load_analysis(analysis_id) + + +@app.post("/api/analyses/{analysis_id}/dismissals") +async def dismiss_issue(analysis_id: str, request: DismissRequest): + """Dismiss a finding for this analysis's target; returns the updated analysis.""" + analysis = _load_analysis(analysis_id) + target = _analysis_target(analysis) + if not target: + raise HTTPException(status_code=400, detail="Only repository and action audits can dismiss findings") + for node in analysis["graph"]["nodes"]: + issue = next((i for i in node.get("issues", []) if i.get("fingerprint") == request.fingerprint), None) + if issue: + dismissals.add(target, request.fingerprint, issue, node["id"], request.reason) + return _load_analysis(analysis_id) + raise HTTPException(status_code=404, detail="Finding not found in this analysis") + + +@app.delete("/api/analyses/{analysis_id}/dismissals/{fingerprint}") +async def restore_issue(analysis_id: str, fingerprint: str): + """Undo a dismissal; returns the updated analysis.""" + analysis = _load_analysis(analysis_id) + target = _analysis_target(analysis) + if not target or not dismissals.remove(target, fingerprint): + raise HTTPException(status_code=404, detail="Dismissal not found") + return _load_analysis(analysis_id) + + +@app.get("/api/dismissals") +async def list_dismissals(target: str): + """Dismissed findings for a repository or action.""" + return dismissals.list(target) + + @app.delete("/api/analyses/{analysis_id}") async def delete_analysis(analysis_id: str): """Delete an analysis by ID.""" diff --git a/backend/tests/test_dismissals.py b/backend/tests/test_dismissals.py new file mode 100644 index 0000000..a71f528 --- /dev/null +++ b/backend/tests/test_dismissals.py @@ -0,0 +1,219 @@ +"""Tests for dismissing findings (fingerprints, store, stats and API).""" +import copy +import json + +import pytest +from fastapi.testclient import TestClient + +import main +from analysis_storage import AnalysisStorage +from dismissals import DismissalStore, apply_dismissals, fingerprint +from graph_builder import GraphBuilder + + +def _older_version_issue(latest="v5", line=12): + return { + "type": "older_action_version", + "severity": "medium", + "message": f"Action 'actions/checkout@v3' uses version 'v3', but the latest version is '{latest}'.", + "action": "actions/checkout@v3", + "version": "v3", + "latest_version": latest, + "line_number": line, + "evidence": {"latest": latest}, + "recommendation": "See docs", + } + + +class TestFingerprint: + def test_ignores_line_numbers_and_latest_version(self): + a = fingerprint("o/r:ci.yml", _older_version_issue("v5", 12)) + b = fingerprint("o/r:ci.yml", _older_version_issue("v6", 40)) + assert a == b + + def test_depends_on_node(self): + issue = _older_version_issue() + assert fingerprint("o/r:ci.yml", issue) != fingerprint("o/r:release.yml", issue) + + def test_depends_on_identifying_fields(self): + other = dict(_older_version_issue(), version="v2", action="actions/checkout@v2") + assert fingerprint("n", _older_version_issue()) != fingerprint("n", other) + + def test_distinguishes_findings_that_differ_only_in_message(self): + a = {"type": "optional_secret_input", "severity": "low", "message": "Input 'token' is optional"} + b = {"type": "optional_secret_input", "severity": "low", "message": "Input 'api_key' is optional"} + assert fingerprint("n", a) != fingerprint("n", b) + + def test_ignores_severity_changes(self): + issue = _older_version_issue() + assert fingerprint("n", issue) == fingerprint("n", dict(issue, severity="high")) + + +def _graph_with_mirror(): + """A workflow finding mirrored onto a package node, as main.py does.""" + graph = GraphBuilder() + graph.add_node("o/r:ci.yml", "ci.yml", "workflow") + graph.add_node("pkg:npm/left-pad", "left-pad", "package") + shared = {"type": "unpinned_npm_packages", "severity": "medium", "message": "npm install left-pad"} + other = {"type": "dangerous_event", "severity": "high", "message": "pull_request_target"} + graph.add_issues_to_node("o/r:ci.yml", [shared, other]) + graph.add_issues_to_node("pkg:npm/left-pad", [shared]) + graph.add_edge("o/r:ci.yml", "pkg:npm/left-pad") + return graph, shared, other + + +class TestApplyAndStatistics: + def test_tags_fingerprints_without_dismissals(self): + graph, shared, other = _graph_with_mirror() + apply_dismissals(graph.nodes.values(), {}) + assert shared["fingerprint"] == fingerprint("o/r:ci.yml", shared) + assert "dismissed" not in other + stats = graph.get_statistics() + assert stats["total_issues"] == 2 + assert stats["dismissed_issues"] == 0 + + def test_dismissed_issue_leaves_counts_and_node_severity(self): + graph, shared, other = _graph_with_mirror() + apply_dismissals(graph.nodes.values(), {}) + fp = other["fingerprint"] + apply_dismissals(graph.nodes.values(), {fp: {"reason": "intended", "dismissed_at": "t"}}) + + assert other["dismissed"] == {"reason": "intended", "dismissed_at": "t"} + stats = graph.get_statistics() + assert stats["total_issues"] == 1 + assert stats["dismissed_issues"] == 1 + assert stats["severity_counts"] == {"medium": 1} + + data = graph.get_graph_data() + workflow = next(n for n in data["nodes"] if n["id"] == "o/r:ci.yml") + assert workflow["issue_count"] == 1 + assert workflow["severity"] == "medium" + + def test_mirrored_finding_is_dismissed_with_its_source(self): + graph, shared, _ = _graph_with_mirror() + data = json.loads(json.dumps(graph.get_graph_data())) # a stored copy: no shared objects + rebuilt = GraphBuilder.from_graph_data(data) + apply_dismissals(rebuilt.nodes.values(), {}) + mirror = rebuilt.nodes["pkg:npm/left-pad"]["issues"][0] + source = rebuilt.nodes["o/r:ci.yml"]["issues"][0] + assert mirror["fingerprint"] == source["fingerprint"] + + apply_dismissals(rebuilt.nodes.values(), {source["fingerprint"]: {"reason": "", "dismissed_at": "t"}}) + assert rebuilt.get_statistics()["dismissed_issues"] == 1 + package = next(n for n in rebuilt.get_graph_data()["nodes"] if n["id"] == "pkg:npm/left-pad") + assert package["issue_count"] == 0 + assert package["severity"] == "none" + + def test_stored_copy_counts_mirrors_once(self): + graph, _, _ = _graph_with_mirror() + apply_dismissals(graph.nodes.values(), {}) + fresh = graph.get_statistics() + rebuilt = GraphBuilder.from_graph_data(json.loads(json.dumps(graph.get_graph_data()))) + apply_dismissals(rebuilt.nodes.values(), {}) + assert rebuilt.get_statistics() == fresh + + def test_restoring_clears_the_flag(self): + graph, _, other = _graph_with_mirror() + apply_dismissals(graph.nodes.values(), {}) + apply_dismissals(graph.nodes.values(), {other["fingerprint"]: {"reason": "", "dismissed_at": "t"}}) + apply_dismissals(graph.nodes.values(), {}) + assert "dismissed" not in other + + +class TestDismissalStore: + def test_add_list_remove(self, tmp_path): + store = DismissalStore(str(tmp_path / "dismissals.json")) + issue = _older_version_issue() + store.add("o/r", "abc", issue, "o/r:ci.yml", " test workflow ") + + [record] = store.list("o/r") + assert record["fingerprint"] == "abc" + assert record["reason"] == "test workflow" + assert record["type"] == "older_action_version" + assert record["node"] == "o/r:ci.yml" + assert store.get("other/repo") == {} + assert store.get(None) == {} + + # Persisted across instances. + assert "abc" in DismissalStore(str(tmp_path / "dismissals.json")).get("o/r") + + assert store.remove("o/r", "abc") is True + assert store.remove("o/r", "abc") is False + assert store.list("o/r") == [] + + def test_reason_is_capped(self, tmp_path): + store = DismissalStore(str(tmp_path / "d.json")) + record = store.add("o/r", "abc", {}, "n", "x" * 5000) + assert len(record["reason"]) == 500 + + +@pytest.fixture +def api(tmp_path, monkeypatch): + monkeypatch.setattr(main, "storage", AnalysisStorage(str(tmp_path / "analyses"))) + monkeypatch.setattr(main, "dismissals", DismissalStore(str(tmp_path / "dismissals.json"))) + return TestClient(main.app) + + +def _store_analysis(repository="o/r"): + graph, _, _ = _graph_with_mirror() + return main.storage.save_analysis( + repository=repository, + action=None, + graph_data=copy.deepcopy(graph.get_graph_data()), + statistics=graph.get_statistics(), + ) + + +def _issue(analysis, node_id, issue_type): + node = next(n for n in analysis["graph"]["nodes"] if n["id"] == node_id) + return next(i for i in node["issues"] if i["type"] == issue_type) + + +class TestDismissalEndpoints: + def test_dismiss_and_restore(self, api): + analysis_id = _store_analysis() + analysis = api.get(f"/api/analyses/{analysis_id}").json() + fp = _issue(analysis, "o/r:ci.yml", "dangerous_event")["fingerprint"] + + response = api.post(f"/api/analyses/{analysis_id}/dismissals", json={"fingerprint": fp, "reason": "intended"}) + assert response.status_code == 200 + updated = response.json() + assert _issue(updated, "o/r:ci.yml", "dangerous_event")["dismissed"]["reason"] == "intended" + assert updated["statistics"]["total_issues"] == 1 + assert updated["statistics"]["dismissed_issues"] == 1 + + [record] = api.get("/api/dismissals", params={"target": "o/r"}).json() + assert record["fingerprint"] == fp + + response = api.delete(f"/api/analyses/{analysis_id}/dismissals/{fp}") + assert response.status_code == 200 + restored = response.json() + assert "dismissed" not in _issue(restored, "o/r:ci.yml", "dangerous_event") + assert restored["statistics"]["dismissed_issues"] == 0 + + def test_dismissal_carries_over_to_other_runs_of_the_target(self, api): + first = _store_analysis() + second = _store_analysis() + other_repo = _store_analysis("someone/else") + fp = _issue(api.get(f"/api/analyses/{first}").json(), "o/r:ci.yml", "dangerous_event")["fingerprint"] + api.post(f"/api/analyses/{first}/dismissals", json={"fingerprint": fp}) + + assert _issue(api.get(f"/api/analyses/{second}").json(), "o/r:ci.yml", "dangerous_event").get("dismissed") + assert not _issue(api.get(f"/api/analyses/{other_repo}").json(), "o/r:ci.yml", "dangerous_event").get("dismissed") + + def test_unknown_fingerprint(self, api): + analysis_id = _store_analysis() + response = api.post(f"/api/analyses/{analysis_id}/dismissals", json={"fingerprint": "nope"}) + assert response.status_code == 404 + assert api.delete(f"/api/analyses/{analysis_id}/dismissals/nope").status_code == 404 + + def test_unknown_analysis(self, api): + response = api.post("/api/analyses/missing/dismissals", json={"fingerprint": "x"}) + assert response.status_code == 404 + + def test_inline_yaml_analysis_cannot_dismiss(self, api): + analysis_id = _store_analysis(repository=None) + analysis = api.get(f"/api/analyses/{analysis_id}").json() + fp = _issue(analysis, "o/r:ci.yml", "dangerous_event")["fingerprint"] + response = api.post(f"/api/analyses/{analysis_id}/dismissals", json={"fingerprint": fp}) + assert response.status_code == 400 diff --git a/docs/content/api-reference.md b/docs/content/api-reference.md index dec79ed..9070831 100644 --- a/docs/content/api-reference.md +++ b/docs/content/api-reference.md @@ -3,7 +3,7 @@ title: "API Reference" description: "Auto-generated API documentation from FastAPI OpenAPI schema" --- -> Auto-generated by `scripts/generate_api_docs.py` on `2026-04-23T21:47:28+00:00`. +> Auto-generated by `scripts/generate_api_docs.py` on `2026-09-27T21:14:12+00:00`. ## OpenAPI Endpoints @@ -76,6 +76,52 @@ Delete an analysis by ID. | `200` | Successful Response | application/json | | `422` | Validation Error | application/json | +### `POST /api/analyses/{analysis_id}/dismissals` + +**Summary:** Dismiss Issue +**Operation ID:** `dismiss_issue_api_analyses__analysis_id__dismissals_post` + +Dismiss a finding for this analysis's target; returns the updated analysis. + +**Parameters** + +| Name | In | Required | Type | Description | +| --- | --- | --- | --- | --- | +| `analysis_id` | `path` | yes | `string` | | + +**Request Body** + +- Required: yes +- `application/json`: `DismissRequest` + +**Responses** + +| Status | Description | Content Types | +| --- | --- | --- | +| `200` | Successful Response | application/json | +| `422` | Validation Error | application/json | + +### `DELETE /api/analyses/{analysis_id}/dismissals/{fingerprint}` + +**Summary:** Restore Issue +**Operation ID:** `restore_issue_api_analyses__analysis_id__dismissals__fingerprint__delete` + +Undo a dismissal; returns the updated analysis. + +**Parameters** + +| Name | In | Required | Type | Description | +| --- | --- | --- | --- | --- | +| `analysis_id` | `path` | yes | `string` | | +| `fingerprint` | `path` | yes | `string` | | + +**Responses** + +| Status | Description | Content Types | +| --- | --- | --- | +| `200` | Successful Response | application/json | +| `422` | Validation Error | application/json | + ### `POST /api/audit` **Summary:** Audit @@ -152,6 +198,26 @@ Audit a raw YAML workflow file. | `200` | Successful Response | application/json | | `422` | Validation Error | application/json | +### `GET /api/dismissals` + +**Summary:** List Dismissals +**Operation ID:** `list_dismissals_api_dismissals_get` + +Dismissed findings for a repository or action. + +**Parameters** + +| Name | In | Required | Type | Description | +| --- | --- | --- | --- | --- | +| `target` | `query` | yes | `string` | | + +**Responses** + +| Status | Description | Content Types | +| --- | --- | --- | +| `200` | Successful Response | application/json | +| `422` | Validation Error | application/json | + ### `GET /api/health` **Summary:** Health @@ -209,6 +275,16 @@ Serve frontend for all non-API routes. | `github_token` | `string | null` | no | | | `yaml_content` | `string` | yes | | +### `DismissRequest` + +- **Type:** `object` +- **Description:** No description. + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `fingerprint` | `string` | yes | | +| `reason` | `string` | no | | + ### `HTTPValidationError` - **Type:** `object` diff --git a/docs/content/usage.md b/docs/content/usage.md index 8e23b7a..9466cc2 100644 --- a/docs/content/usage.md +++ b/docs/content/usage.md @@ -126,6 +126,14 @@ A `dangerous_event` finding with the event that triggered it. Each finding explains the risk, gives a mitigation, and shows the **evidence** it was raised on: the event, step, line or value in the workflow. The link at the bottom opens that check's page in the [check reference](/vulnerabilities/). +### Dismiss a finding + +If a finding is a false positive or a risk you accept, click **Dismiss finding** at the bottom of its details and, optionally, note why. A dismissed finding leaves the graph, the counts and search, and stays dismissed when you audit the same repository or action again. It comes back if the finding itself changes, for example when the step it points at is edited. + +To review dismissals, open the **Findings** table and tick **Show dismissed**. Open a dismissed finding to see when it was dismissed and why, and click **Restore** to bring it back. + +Dismissals are stored with your saved analyses in the container's `data` directory. Findings from a pasted workflow (**Create a secure workflow**) can't be dismissed, since there is no repository to remember them against. + ### Share a node **Share** in the panel header creates a link to that node and its findings. diff --git a/docs/static/openapi.json b/docs/static/openapi.json index 0c6cc97..dfddeaf 100644 --- a/docs/static/openapi.json +++ b/docs/static/openapi.json @@ -299,6 +299,139 @@ } } }, + "/api/analyses/{analysis_id}/dismissals": { + "post": { + "summary": "Dismiss Issue", + "description": "Dismiss a finding for this analysis's target; returns the updated analysis.", + "operationId": "dismiss_issue_api_analyses__analysis_id__dismissals_post", + "parameters": [ + { + "name": "analysis_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Analysis Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DismissRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/analyses/{analysis_id}/dismissals/{fingerprint}": { + "delete": { + "summary": "Restore Issue", + "description": "Undo a dismissal; returns the updated analysis.", + "operationId": "restore_issue_api_analyses__analysis_id__dismissals__fingerprint__delete", + "parameters": [ + { + "name": "analysis_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Analysis Id" + } + }, + { + "name": "fingerprint", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Fingerprint" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/dismissals": { + "get": { + "summary": "List Dismissals", + "description": "Dismissed findings for a repository or action.", + "operationId": "list_dismissals_api_dismissals_get", + "parameters": [ + { + "name": "target", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Target" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/{full_path}": { "get": { "summary": "Serve Frontend", @@ -408,6 +541,24 @@ ], "title": "AuditYAMLRequest" }, + "DismissRequest": { + "properties": { + "fingerprint": { + "type": "string", + "title": "Fingerprint" + }, + "reason": { + "type": "string", + "title": "Reason", + "default": "" + } + }, + "type": "object", + "required": [ + "fingerprint" + ], + "title": "DismissRequest" + }, "HTTPValidationError": { "properties": { "detail": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ebbad6e..13da8b5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react' +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { flushSync } from 'react-dom' import InputForm from './components/InputForm' import ThemeToggle from './components/ThemeToggle' @@ -14,11 +14,15 @@ import SearchOverlay from './components/SearchOverlay' import SearchResultsPage from './components/SearchResultsPage' import YAMLEditorPanel from './components/YAMLEditorPanel' import HomeBackdrop from './components/HomeBackdrop' +import { DismissalContext, withoutDismissed } from './dismissals' import './App.css' function App() { const [graphData, setGraphData] = useState(null) const [statistics, setStatistics] = useState(null) + // Which stored analysis is on screen, and the repository or action it + // audited (dismissals are remembered per target). + const [analysisMeta, setAnalysisMeta] = useState(null) const [loading, setLoading] = useState(false) const [loadingStage, setLoadingStage] = useState('') const [loadingLogs, setLoadingLogs] = useState([]) @@ -59,6 +63,17 @@ function App() { return () => clearInterval(timer) }, [loading]) + const showAnalysis = useCallback((analysis) => { + setGraphData(analysis?.graph || null) + setStatistics(analysis?.statistics || null) + setAnalysisMeta(analysis?.id + ? { id: analysis.id, target: analysis.repository || analysis.action || null } + : null) + }, []) + + // Every view but the findings table works on active findings only. + const visibleGraph = useMemo(() => withoutDismissed(graphData), [graphData]) + const isMac = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform) // First run shows a single search box; anything else is the workspace. @@ -99,8 +114,7 @@ function App() { // Reset application state const handleReset = () => runLayoutTransition(() => { - setGraphData(null) - setStatistics(null) + showAnalysis(null) setError(null) setSelectedNode(null) setSelectedIssue(null) @@ -155,11 +169,11 @@ function App() { }, [graphData]) const findOtherIssueInstances = useCallback((issue) => { - if (!graphData?.nodes || !issue?.type) { + if (!visibleGraph?.nodes || !issue?.type) { return [] } - return graphData.nodes.flatMap(node => { + return visibleGraph.nodes.flatMap(node => { if (node.id === issue.nodeId) { return [] } @@ -172,7 +186,7 @@ function App() { id: node.id, })) }) - }, [graphData]) + }, [visibleGraph]) const handleIssueSelect = useCallback((issue) => { setSelectedNode(null) @@ -257,8 +271,7 @@ function App() { } const result = await response.json() - setGraphData(result.graph) - setStatistics(result.statistics) + showAnalysis(result) setShareMode(false) setRepositoryAuditStatus({ isAudited: true }) @@ -297,8 +310,7 @@ function App() { const response = await fetch(`/api/analyses/${analysisId}`) if (response.ok) { const analysis = await response.json() - setGraphData(analysis.graph) - setStatistics(analysis.statistics) + showAnalysis(analysis) setShareMode(false) setViewMode('graph') @@ -328,8 +340,7 @@ function App() { } const handleLoadAnalysis = (analysis) => runLayoutTransition(() => { - setGraphData(analysis.graph) - setStatistics(analysis.statistics) + showAnalysis(analysis) setError(null) // A filter or selection from the previous analysis refers to nodes that // may not exist in this one. @@ -344,6 +355,39 @@ function App() { } }) + // Dismiss or restore a finding. The server answers with the analysis as it + // now reads, counts included. + const updateDismissal = useCallback(async (request) => { + const response = await fetch(`/api/analyses/${analysisMeta.id}/dismissals${request.path}`, { + method: request.method, + headers: { 'Content-Type': 'application/json' }, + body: request.body ? JSON.stringify(request.body) : undefined, + }) + if (!response.ok) { + let detail = 'Could not update the finding' + try { + detail = (await response.json()).detail || detail + } catch { + // keep the generic message + } + throw new Error(detail) + } + showAnalysis(await response.json()) + }, [analysisMeta, showAnalysis]) + + const dismissalActions = useMemo(() => ({ + canDismiss: Boolean(analysisMeta?.target) && !shareMode, + dismiss: (issue, reason) => updateDismissal({ + method: 'POST', + path: '', + body: { fingerprint: issue.fingerprint, reason }, + }), + restore: (issue) => updateDismissal({ + method: 'DELETE', + path: `/${encodeURIComponent(issue.fingerprint)}`, + }), + }), [analysisMeta, shareMode, updateDismissal]) + const handleAudit = async (data) => { if (auditAbortRef.current) { auditAbortRef.current.abort() @@ -415,8 +459,7 @@ function App() { receivedResult = true setSelectedNode(null) setSelectedIssue(null) - setGraphData(parsed.graph) - setStatistics(parsed.statistics) + showAnalysis(parsed) setGraphFilter(null) setViewMode('graph') addLog('Audit complete') @@ -454,6 +497,7 @@ function App() { } return ( +
{isHome ? (
@@ -623,7 +667,7 @@ function App() { { setSelectedNode(node) setShowSearchResults(false) @@ -715,7 +759,7 @@ function App() { {graphData ? ( viewMode === 'graph' ? ( setGraphFilter(null)} @@ -725,7 +769,7 @@ function App() { // Table view: show different tables based on filter graphFilter?.type === 'has_dependencies' ? ( @@ -738,7 +782,7 @@ function App() { /> ) : ( @@ -784,7 +828,7 @@ function App() { {selectedNode && ( { setSelectedNode(null) setShareMode(false) @@ -814,7 +858,7 @@ function App() { {showSearchOverlay && graphData && ( setShowSearchOverlay(false)} onNodeSelect={(node) => { setSelectedIssue(null) @@ -861,8 +905,7 @@ function App() { } const result = await response.json() - setGraphData(result.graph) - setStatistics(result.statistics) + showAnalysis(result) setGraphFilter(null) setViewMode('graph') @@ -890,6 +933,7 @@ function App() { )}
+
) } diff --git a/frontend/src/components/IssueDetailsModal.css b/frontend/src/components/IssueDetailsModal.css index ba98457..6c3c902 100644 --- a/frontend/src/components/IssueDetailsModal.css +++ b/frontend/src/components/IssueDetailsModal.css @@ -355,3 +355,149 @@ color: #7c3aed; } + +/* ---- Triage: dismiss / restore ------------------------------------ */ + +.dismissed-pill { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.6rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + color: var(--ink-3); + background: var(--surface-sunken); + border: 1px solid var(--line); + font-family: var(--font-sans); +} + +.issue-triage { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.5rem; + border-top: 1px solid var(--line); + background: var(--surface-sunken); + font-family: var(--font-sans); +} + +.issue-triage-hint, +.issue-triage-status { + flex: 1; + min-width: 0; + font-size: 0.875rem; + color: var(--ink-3); +} + +.issue-triage-status { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.issue-triage-status strong { + color: var(--ink); + font-weight: 600; +} + +.issue-triage-reason { + overflow-wrap: anywhere; +} + +.issue-triage-form { + align-items: stretch; + flex-direction: column; +} + +.issue-triage-label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--ink); +} + +.issue-triage-label span { + font-weight: 400; + color: var(--ink-3); +} + +.issue-triage-input { + width: 100%; + padding: 0.5rem 0.75rem; + font: inherit; + font-size: 0.875rem; + color: var(--ink); + background: var(--surface); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); +} + +.issue-triage-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: var(--focus-ring); +} + +.issue-triage-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +.issue-triage-note { + margin: 0; + font-size: 0.75rem; + color: var(--ink-3); +} + +.issue-triage-error { + flex-basis: 100%; + margin: 0; + font-size: 0.8125rem; + color: var(--danger-ink); +} + +.triage-btn { + padding: 0.45rem 0.9rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--ink); + background: var(--surface); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-xs), var(--bevel); + cursor: pointer; + white-space: nowrap; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.triage-btn:hover:not(:disabled) { + background: var(--surface-hover); +} + +.triage-btn:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.triage-btn:disabled { + opacity: 0.6; + cursor: default; +} + +.triage-btn-quiet { + background: none; + border-color: transparent; + box-shadow: none; +} + +.triage-btn-primary { + color: var(--on-inverse); + background: linear-gradient(180deg, var(--btn-top), var(--btn-bottom)); + border-color: var(--btn-border); + box-shadow: var(--shadow-xs), var(--bevel-dark); +} + +.triage-btn-primary:hover:not(:disabled) { + background: var(--inverse-hover); +} diff --git a/frontend/src/components/IssueDetailsModal.jsx b/frontend/src/components/IssueDetailsModal.jsx index 108c43f..7d78892 100644 --- a/frontend/src/components/IssueDetailsModal.jsx +++ b/frontend/src/components/IssueDetailsModal.jsx @@ -1,6 +1,97 @@ -import React from 'react' +import React, { useState } from 'react' +import { isDismissed, useDismissals } from '../dismissals' import './IssueDetailsModal.css' +// Dismiss (with an optional reason) or restore the finding. Dismissals are +// remembered for the audited repository or action, so they hold on re-runs. +function TriageBar({ issue, onDone }) { + const { canDismiss, dismiss, restore } = useDismissals() + const [editing, setEditing] = useState(false) + const [reason, setReason] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + if (!issue.fingerprint || (!canDismiss && !isDismissed(issue))) return null + + const run = async (action) => { + setBusy(true) + setError(null) + try { + await action() + onDone() + } catch (err) { + setError(err.message) + setBusy(false) + } + } + + if (isDismissed(issue)) { + const { reason: savedReason, dismissed_at: dismissedAt } = issue.dismissed + const date = dismissedAt ? new Date(dismissedAt).toLocaleDateString() : null + return ( +
+
+ Dismissed{date ? ` on ${date}` : ''} + {savedReason && {savedReason}} +
+ {canDismiss && ( + + )} + {error &&

{error}

} +
+ ) + } + + if (!editing) { + return ( +
+ False positive or accepted risk? + +
+ ) + } + + return ( +
{ + event.preventDefault() + run(() => dismiss(issue, reason)) + }} + > + + setReason(event.target.value)} + autoFocus + /> +
+ + +
+

+ Hidden from counts and views on every audit of this target. You can restore it from the findings table. +

+ {error &&

{error}

} +
+ ) +} + function IssueDetailsModal({ issue, otherInstances, onClose }) { if (!issue) return null @@ -301,6 +392,7 @@ function IssueDetailsModal({ issue, otherInstances, onClose }) {

{formatTitle(issue.type)}

+ {isDismissed(issue) && Dismissed} {issue.type === 'trufflehog_secret_detected' && issue.evidence && ( )}
+ +
) diff --git a/frontend/src/components/IssuesTable.css b/frontend/src/components/IssuesTable.css index 1779fb2..9e27caf 100644 --- a/frontend/src/components/IssuesTable.css +++ b/frontend/src/components/IssuesTable.css @@ -140,3 +140,45 @@ border: 1px solid var(--line); } + +.issues-table-meta { + display: flex; + align-items: center; + gap: 1rem; +} + +.show-dismissed { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8125rem; + color: var(--ink-3); + cursor: pointer; + user-select: none; +} + +.show-dismissed input { + accent-color: var(--accent); + margin: 0; +} + +.issues-table-row.is-dismissed td { + opacity: 0.55; +} + +.issues-table-row.is-dismissed .severity-badge { + filter: grayscale(1); +} + +.dismissed-chip { + display: inline-block; + margin-left: 0.5rem; + padding: 0.05rem 0.45rem; + font-size: 0.6875rem; + font-weight: 600; + color: var(--ink-3); + background: var(--surface-sunken); + border: 1px solid var(--line); + border-radius: 999px; + vertical-align: middle; +} diff --git a/frontend/src/components/IssuesTable.jsx b/frontend/src/components/IssuesTable.jsx index b2bde69..bb50fbb 100644 --- a/frontend/src/components/IssuesTable.jsx +++ b/frontend/src/components/IssuesTable.jsx @@ -1,7 +1,10 @@ -import React, { useMemo } from 'react' +import React, { useMemo, useState } from 'react' +import { isDismissed } from '../dismissals' import './IssuesTable.css' function IssuesTable({ graphData, filter, onNodeSelect, onIssueSelect }) { + const [showDismissed, setShowDismissed] = useState(false) + const getSeverityColor = (severity) => { switch (severity) { case 'critical': @@ -42,28 +45,33 @@ function IssuesTable({ graphData, filter, onNodeSelect, onIssueSelect }) { const MIRROR_TYPES = new Set(['package', 'image', 'container_image', 'docker_image']) const keyOf = ({ nodeId, nodeLabel, nodeType, ...finding }) => JSON.stringify(finding) const sourceKeys = new Set(issues.filter(i => !MIRROR_TYPES.has(i.nodeType)).map(keyOf)) - const unique = issues.filter(i => !MIRROR_TYPES.has(i.nodeType) || !sourceKeys.has(keyOf(i))) + // A rule can report the same finding twice on one node; the backend + // counts it once (by fingerprint), so list it once. + const seen = new Set() + const unique = issues.filter(i => { + if (MIRROR_TYPES.has(i.nodeType) && sourceKeys.has(keyOf(i))) return false + if (!i.fingerprint) return true + const key = `${i.nodeId}|${i.fingerprint}` + if (seen.has(key)) return false + seen.add(key) + return true + }) const rank = { critical: 0, high: 1, medium: 2, low: 3 } unique.sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9) || String(a.type).localeCompare(String(b.type))) issues.length = 0 issues.push(...unique) // Apply filter if present - if (filter) { - if (filter.type === 'severity' && filter.severity) { - return issues.filter(issue => issue.severity === filter.severity) - } - if (filter.type === 'has_issues') { - // Show all issues when filtering by has_issues - return issues - } - // For other filter types, show all issues - return issues + if (filter?.type === 'severity' && filter.severity) { + return issues.filter(issue => issue.severity === filter.severity) } - return issues }, [graphData, filter]) + const dismissedCount = allIssues.filter(isDismissed).length + const shownIssues = showDismissed ? allIssues : allIssues.filter(issue => !isDismissed(issue)) + const activeCount = allIssues.length - dismissedCount + const handleRowClick = (issue) => { if (onIssueSelect) { onIssueSelect(issue) @@ -90,12 +98,28 @@ function IssuesTable({ graphData, filter, onNodeSelect, onIssueSelect }) {

Security Issues

-
{allIssues.length} issue{allIssues.length !== 1 ? 's' : ''}
+
+ {dismissedCount > 0 && ( + + )} +
{activeCount} issue{activeCount !== 1 ? 's' : ''}
+
- {allIssues.length === 0 ? ( + {shownIssues.length === 0 ? (
-

No issues found{filter ? ' matching the current filter' : ''}

+

+ {dismissedCount > 0 && !showDismissed + ? `No open issues${filter ? ' matching the current filter' : ''}. ${dismissedCount} dismissed.` + : `No issues found${filter ? ' matching the current filter' : ''}`} +

) : (
@@ -110,11 +134,11 @@ function IssuesTable({ graphData, filter, onNodeSelect, onIssueSelect }) { - {allIssues.map((issue, index) => ( + {shownIssues.map((issue, index) => ( handleRowClick(issue)} - className="issues-table-row" + className={`issues-table-row ${isDismissed(issue) ? 'is-dismissed' : ''}`} > {issue.type || 'Unknown'} + {isDismissed(issue) && ( + Dismissed + )}
diff --git a/frontend/src/components/Statistics.css b/frontend/src/components/Statistics.css index e395e4f..bb04b99 100644 --- a/frontend/src/components/Statistics.css +++ b/frontend/src/components/Statistics.css @@ -197,3 +197,23 @@ .clear-filter:hover { background: var(--accent-soft); } + +/* Dismissed findings are out of the counts above; say so, and link to them. */ +.dismissed-note { + display: block; + width: 100%; + margin-top: 8px; + padding: 4px 6px; + font-size: 0.75rem; + color: var(--ink-3); + text-align: left; + background: none; + border: none; + border-radius: var(--radius-xs); + cursor: pointer; +} + +.dismissed-note:hover { + color: var(--accent); + background: var(--accent-soft); +} diff --git a/frontend/src/components/Statistics.jsx b/frontend/src/components/Statistics.jsx index 8339bd3..68f162b 100644 --- a/frontend/src/components/Statistics.jsx +++ b/frontend/src/components/Statistics.jsx @@ -60,6 +60,17 @@ function Statistics({ data, onFilterChange, onViewModeChange, currentViewMode, c ))}
+ {data.dismissed_issues > 0 && ( + + )} +
{['graph', 'table'].map(mode => (