diff --git a/src/macaron/console.py b/src/macaron/console.py index 10a624dd4..b81ad92f6 100644 --- a/src/macaron/console.py +++ b/src/macaron/console.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 - 2025, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2025 - 2026, Oracle and/or its affiliates. All rights reserved. # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. """This module implements a rich console handler for logging.""" @@ -306,6 +306,7 @@ def __init__(self, *args: Any, verbose: bool = False, **kwargs: Any) -> None: self.policies_table = Table(box=None) self.components_violates_table = Table(box=None) self.components_satisfy_table = Table(box=None) + self.policy_evidence_table = Table(box=None) self.policy_summary_table = Table(show_header=False, box=None) self.policy_summary: dict[str, str | Status] = { "Passed Policies": "None", @@ -537,7 +538,7 @@ def update_policy_engine(self, results: dict) -> None: components_violates_table.add_column("PURL", justify="left") components_violates_table.add_column("Policy Name", justify="left") - for values in results["component_violates_policy"]: + for values in results.get("component_violates_policy", []): components_violates_table.add_row(values[0], values[1], values[2]) self.components_violates_table = components_violates_table @@ -547,16 +548,37 @@ def update_policy_engine(self, results: dict) -> None: components_satisfy_table.add_column("PURL", justify="left") components_satisfy_table.add_column("Policy Name", justify="left") - for values in results["component_satisfies_policy"]: + for values in results.get("component_satisfies_policy", []): components_satisfy_table.add_row(values[0], values[1], values[2]) self.components_satisfy_table = components_satisfy_table + standard_outputs = { + "passed_policies", + "failed_policies", + "component_satisfies_policy", + "component_violates_policy", + } + policy_evidence_table = Table(box=None) + policy_evidence_table.add_column("Relation", justify="left") + policy_evidence_table.add_column("Evidence", justify="left") + for relation, rows in sorted(results.items()): + if relation in standard_outputs: + continue + for values in rows: + policy_evidence_table.add_row(relation, " | ".join(values)) + + self.policy_evidence_table = policy_evidence_table + self.policy_summary["Passed Policies"] = ( - "\n".join(policy[0] for policy in results["passed_policies"]) if results["passed_policies"] else "None" + "\n".join(policy[0] for policy in results.get("passed_policies", [])) + if results.get("passed_policies", []) + else "None" ) self.policy_summary["Failed Policies"] = ( - "\n".join(policy[0] for policy in results["failed_policies"]) if results["failed_policies"] else "None" + "\n".join(policy[0] for policy in results.get("failed_policies", [])) + if results.get("failed_policies", []) + else "None" ) self.generate_policy_summary_table() @@ -756,6 +778,12 @@ def make_layout(self) -> Group: "[bold red] Components Violate Policy[/] [white not italic]None[/]", ] layout = layout + ["", self.policy_summary_table] + if self.policy_evidence_table.row_count > 0: + layout = layout + [ + "", + "[bold yellow] Policy Evidence[/]", + self.policy_evidence_table, + ] if self.verification_summary_attestation: vsa_table = Table(show_header=False, box=None) vsa_table.add_column("Detail", justify="left") diff --git a/src/macaron/dependency_analyzer/cyclonedx.py b/src/macaron/dependency_analyzer/cyclonedx.py index c46a8a773..eedcdb09e 100644 --- a/src/macaron/dependency_analyzer/cyclonedx.py +++ b/src/macaron/dependency_analyzer/cyclonedx.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023 - 2025, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2023 - 2026, Oracle and/or its affiliates. All rights reserved. # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. """This module contains helper functions to process CycloneDX SBOM.""" @@ -357,10 +357,9 @@ def resolve_dependencies(main_ctx: Any, sbom_path: str, recursive: bool = False) # Start resolving dependencies. logger.info( - "Running %s version %s dependency analyzer on %s", + "Running %s version %s dependency analyzer.", dep_analyzer.tool_name, dep_analyzer.tool_version, - find_report_output_path(main_ctx.component.repository.fs_path), ) log_path = os.path.join( diff --git a/src/macaron/output_reporter/malware_summary.py b/src/macaron/output_reporter/malware_summary.py new file mode 100644 index 000000000..2b46383d1 --- /dev/null +++ b/src/macaron/output_reporter/malware_summary.py @@ -0,0 +1,175 @@ +# Copyright (c) 2026 - 2026, Oracle and/or its affiliates. All rights reserved. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. + +"""Helpers for building user-friendly malware findings for output reports.""" + +from __future__ import annotations + +from typing import Any + +from macaron.slsa_analyzer.checks.check_result import CheckResult, CheckResultType + +MALICIOUS_METADATA_CHECK_ID = "mcn_detect_malicious_metadata_1" + +_HEURISTIC_REASONS = { + "empty_project_link": "Package metadata has no useful project links.", + "source_code_repo": "Macaron could not identify a matching source repository.", + "one_release": "The package has only one release.", + "high_release_frequency": "The package has an unusual release cadence.", + "unchanged_release": "Multiple releases appear to contain unchanged code.", + "closer_release_join_date": "A maintainer joined close to the release time.", + "suspicious_setup": "The package contains suspicious setup/install behavior.", + "wheel_absence": "The package omits a wheel, which can force source install behavior.", + "anomalous_version": "The package version looks unusual for the package history.", + "typosquatting_presence": "The package name resembles another package.", + "fake_email": "Maintainer metadata contains suspicious email information.", + "similar_projects": "Macaron found similarly structured package projects.", + "package_description_intent": "The package description does not clearly explain benign intent.", + "type_stub_file": "The package does not look like a legitimate type-stub package.", + "suspicious_patterns": "Source code analysis found suspicious Python malware patterns.", +} + +_CATEGORY_KEYWORDS = ( + ("Command and control", ("c2", "domain", "url", "endpoint", "network", "exfil")), + ("Payload staging", ("payload", "download", "rope", "pyz", "dropper")), + ("Persistence", ("persist", "systemd", "service", "unit")), + ("Credential access", ("credential", "secret", "token", "aws", "azure", "gcp", "kubernetes", "vault", "ssh")), + ("Suspicious execution", ("obfuscation", "inline", "import", "exec", "eval", "system", "subprocess", "command")), +) + + +def _display_value(value: Any) -> Any: + """Return a JSON-serializable display value for enums and primitive values.""" + if hasattr(value, "value"): + return value.value + return value + + +def _confidence_value(value: Any) -> float | None: + """Return a primitive confidence score.""" + display_value = _display_value(value) + if isinstance(display_value, (int, float)): + return float(display_value) + return None + + +def _categorize_source_finding(rule_id: str, message: str) -> str: + """Map a source-code rule finding to a user-facing behavior category.""" + haystack = f"{rule_id} {message}".lower() + for category, keywords in _CATEGORY_KEYWORDS: + if any(keyword in haystack for keyword in keywords): + return category + return "Suspicious source pattern" + + +def _build_failed_heuristics(result: dict[Any, Any]) -> list[dict[str, str]]: + """Build a readable list of failed heuristics.""" + failed_heuristics = [] + for heuristic, heuristic_result in result.items(): + heuristic_name = str(_display_value(heuristic)) + status = str(_display_value(heuristic_result)) + if status != "FAIL": + continue + failed_heuristics.append( + { + "name": heuristic_name, + "reason": _HEURISTIC_REASONS.get(heuristic_name, "This heuristic flagged suspicious package behavior."), + } + ) + return failed_heuristics + + +def _build_behavioral_findings(detail_information: dict[str, Any]) -> list[dict[str, Any]]: + """Build behavior-oriented findings from source-code rule evidence.""" + source_findings = detail_information.get("enabled_sourcecode_rule_findings") + if not isinstance(source_findings, dict): + return [] + + grouped_findings: dict[str, dict[str, Any]] = {} + for rule_id, finding in source_findings.items(): + if not isinstance(finding, dict): + continue + message = finding.get("message", "Source-code rule matched suspicious code.") + if not isinstance(message, str): + message = "Source-code rule matched suspicious code." + + category = _categorize_source_finding(rule_id, message) + grouped = grouped_findings.setdefault( + category, + { + "category": category, + "reason": message, + "rules": [], + "evidence": [], + }, + ) + grouped["rules"].append(rule_id) + detections = finding.get("detections", []) + if isinstance(detections, list): + grouped["evidence"].extend(detection for detection in detections if isinstance(detection, dict)) + + return list(grouped_findings.values()) + + +def _recommended_actions(verdict: str, behavioral_findings: list[dict[str, Any]]) -> list[str]: + """Return concise next steps for a malware finding.""" + if verdict == "no_suspicious_indicators": + return [] + + actions = [ + "Block the package from promotion until the finding is reviewed.", + "Send the package artifact to dynamic analysis if available.", + ] + categories = {finding["category"] for finding in behavioral_findings} + if "Credential access" in categories: + actions.append("Investigate whether systems that imported this package exposed credentials.") + if "Command and control" in categories: + actions.append("Review network controls for the reported domains or endpoints.") + return actions + + +def build_malware_summary(check_result: CheckResult) -> dict[str, Any] | None: + """Build a user-friendly malware summary for the malicious metadata check.""" + if check_result.check.check_id != MALICIOUS_METADATA_CHECK_ID: + return None + if check_result.result.result_type != CheckResultType.FAILED: + return None + + summaries = [] + for result_table in check_result.result.result_tables: + result = getattr(result_table, "result", {}) or {} + detail_information = getattr(result_table, "detail_information", {}) or {} + known_malware = getattr(result_table, "known_malware", None) + confidence = _confidence_value(getattr(result_table, "confidence", None)) + + failed_heuristics = _build_failed_heuristics(result) + behavioral_findings = _build_behavioral_findings(detail_information) + + if known_malware: + verdict = "known_malware" + primary_reason = f"Package is listed as known malware: {known_malware}" + else: + verdict = "suspicious" + if behavioral_findings: + primary_reason = "Source code contains suspicious Python malware patterns." + elif failed_heuristics: + primary_reason = "Package metadata matches suspicious malware heuristics." + else: + primary_reason = "Macaron's malicious package check failed." + + summaries.append( + { + "check_id": check_result.check.check_id, + "verdict": verdict, + "confidence": confidence, + "primary_reason": primary_reason, + "known_malware": known_malware, + "failed_heuristics": failed_heuristics, + "behavioral_findings": behavioral_findings, + "recommended_actions": _recommended_actions(verdict, behavioral_findings), + } + ) + + if not summaries: + return None + return summaries[0] diff --git a/src/macaron/output_reporter/templates/base_template.html b/src/macaron/output_reporter/templates/base_template.html index 0d15b35a9..9bd710ccd 100644 --- a/src/macaron/output_reporter/templates/base_template.html +++ b/src/macaron/output_reporter/templates/base_template.html @@ -22,14 +22,23 @@ body { margin-bottom: 20px; + background: + linear-gradient(180deg, #f8fafc 0, #ffffff 360px), + #ffffff; + color: #22302f; } a { - color: #045fab; + color: #0f766e; line-height: inherit; text-decoration: none; } + a:hover { + color: #0b5f59; + text-decoration: underline; + } + pre { padding-left: 10px; font-size: large; @@ -38,37 +47,61 @@ button { border: 0; - padding: 0; - padding-top: 10px; - padding-bottom: 10px; - background: lightgrey; + padding: 10px 14px; + background: #e2e8f0; box-shadow: none; - border-radius: 0px; + border-radius: 8px; } button:hover:enabled, .tablebutton_current { - background: grey; + background: #0f766e; color: white; } table, th, td { - padding: 10px; - border: 1px black solid; + padding: 11px 12px; + border: 1px solid #dbe5e3; border-collapse: collapse; box-sizing: border-box; } + table { + background: #ffffff; + color: #22302f; + } + + thead th { + background: #0f3f3d; + border-color: #0f3f3d; + color: #f8fafc; + font-size: 13px; + letter-spacing: 0.03em; + text-transform: uppercase; + } + th { min-width: fit-content; text-align: left; + background: #f8fafc; + color: #26403e; + font-weight: 800; } td { word-wrap: break-word; word-break: keep-all; + line-height: 1.45; + } + + tbody tr:nth-child(even) td { + background: #fbfdfc; + } + + tbody tr:hover td { + background: #f0fdfa; } th a:active { @@ -88,6 +121,8 @@ footer { margin-top: 20px; text-align: center; + color: #64748b; + font-size: 13px; } .raw { @@ -110,22 +145,37 @@ #scroll_to_top { position: fixed; - padding: 20px; + width: auto; + padding: 14px 16px; bottom: 20px; right: 30px; font-size: 20px; z-index: 99; + border: 1px solid rgba(15, 118, 110, 0.24); + border-radius: 999px; + background: #0f766e; + color: white; + box-shadow: 0 10px 26px rgba(15, 118, 110, 0.28); } .space_divider { - height: 20px; + height: 16px; } #report_title { - padding: 10px 10px; + width: auto; + margin: 16px; + padding: 24px 20px; + border: 1px solid #cfe7e3; + border-radius: 8px; + background: + linear-gradient(135deg, rgba(15, 63, 61, 0.97), rgba(15, 118, 110, 0.94) 55%, rgba(127, 29, 29, 0.94)), + #0f3f3d; + box-shadow: 0 14px 36px rgba(15, 63, 61, 0.16); + color: white; font-weight: bold; text-align: center; - font-size: 40px; + font-size: 38px; display: flex; flex-direction: column; } @@ -133,64 +183,81 @@ #macaron-svg { align-self: center; padding: 10px; + filter: drop-shadow(0 8px 14px rgba(0, 0, 0, 0.22)); } .red_bg { - background-color: red; + background-color: #dc2626; color: white; text-align: center; font-weight: bold; } .green_bg { - background-color: forestgreen; + background-color: #16a34a; color: white; text-align: center; font-weight: bold; } .lightgreen_bg { - background-color: lightgreen; - color: grey; + background-color: #dcfce7; + color: #166534; text-align: center; font-weight: bold; } .grey_bg { - background-color: grey; - color: black; + background-color: #e2e8f0; + color: #475569; text-align: center; font-weight: bold; } .black_bg { - background-color: black; - color: slategray; + background-color: #0f172a; + color: #cbd5e1; text-align: center; font-weight: bold; } .timestamps { + width: auto; padding-top: 10px; padding-bottom: 10px; border: 0; - font-size: small; - font-weight: lighter; + color: #d9fff9; + font-size: 13px; + font-weight: 500; text-align: center; } .table_caption { - text-align: center; - font-weight: bold; - padding: 10px 10px; - font-size: 25px; + box-sizing: border-box; + width: auto; + margin-bottom: 14px; + padding: 14px 16px; + border-left: 5px solid #0f766e; + border-radius: 8px; + background: linear-gradient(90deg, #ecfdf5, #ffffff); + color: #0f3f3d; + text-align: left; + font-weight: 800; + font-size: 23px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); } .table_sub_caption { - text-align: center; - font-weight: bold; - padding: 10px 10px; - font-size: 18px; + box-sizing: border-box; + width: auto; + margin: 14px 0 10px; + padding: 10px 12px; + border-radius: 8px; + background: #f8fafc; + color: #334155; + text-align: left; + font-weight: 800; + font-size: 16px; } #mcn_prov_sum { @@ -198,9 +265,18 @@ } #prov_justification { - text-align: center; - font-style: italic; - font-size: 18px; + box-sizing: border-box; + width: auto; + margin: 0 0 14px; + padding: 12px 14px; + border: 1px solid #d9e5e2; + border-radius: 8px; + background: #ffffff; + color: #475569; + text-align: left; + font-style: normal; + font-size: 15px; + line-height: 1.45; } /* Format the Slsa requirements column. @@ -210,33 +286,99 @@ white-space: nowrap; } + .report-section { + box-sizing: border-box; + width: calc(100% - 32px); + margin: 16px; + padding: 16px; + border: 1px solid #d9e5e2; + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.96)), + #ffffff; + box-shadow: 0 10px 28px rgba(15, 63, 61, 0.08); + overflow-x: auto; + } + + .report-section table { + width: 100%; + margin: 0; + border: 1px solid #dbe5e3; + border-radius: 8px; + border-collapse: separate; + border-spacing: 0; + overflow: hidden; + } + + .report-section table th:first-child, + .report-section table td:first-child { + border-left: 0; + } + + .report-section table th:last-child, + .report-section table td:last-child { + border-right: 0; + } + + .report-section table tr:first-child th, + .report-section table tr:first-child td { + border-top: 0; + } + + .report-section table tr:last-child th, + .report-section table tr:last-child td { + border-bottom: 0; + } + + .target_info th { + width: 220px; + background: #f0fdfa; + color: #0f3f3d; + } + + .checks_report td:first-child, + .dep_summary td:first-child, + .dep_status td:first-child { + font-weight: 700; + color: #0f3f3d; + } + + .report-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + width: auto; + margin-bottom: 12px; + } + /* Customize the jquery-bonsai expand-all/collapse-all buttons. */ .fancy-button { - border-radius: 6px; box-sizing: border-box; - color: black; + width: auto; + color: #0f3f3d; cursor: pointer; display: inline-block; - font-weight: 500; + font-weight: 800; line-height: 20px; list-style: none; - padding: 6px 16px; + padding: 9px 14px; position: relative; user-select: none; vertical-align: middle; white-space: nowrap; word-wrap: break-word; - font-weight: bold; - border: 2px solid #d5d3d3; - border-radius: 10px; + border: 1px solid #99d5cd; + border-radius: 8px; + background: #ecfdf5; vertical-align: middle; white-space: nowrap; word-wrap: break-word; - margin: 10px; + margin: 0; } .fancy-button:hover { - background-color: grey; + background-color: #0f766e; + color: white; text-decoration: none; transition-duration: 0.1s; } @@ -248,11 +390,26 @@ .tree-view-nested-list, .tree-view-nested-list ul { /* Remove default bullets. This will be replaced with a caret. */ list-style-type: none; - padding: 0 + box-sizing: border-box; + width: auto; + padding: 0; } .tree-view-nested-list li { - padding-left: 2em + padding-left: 1.4em; + line-height: 1.65; + } + + .report-section > .tree-view-nested-list, + #run-checks-tree > .tree-view-nested-list { + padding: 14px; + border: 1px solid #d9e5e2; + border-radius: 8px; + background: #ffffff; + } + + .tree-view-nested-list div { + width: auto; } /* @@ -267,7 +424,7 @@ /* Create the caret using a unicode symbol, and style it. */ .caret::before, .toggler::before { content: "\25B8"; - color: black; + color: #0f766e; display: inline-block; margin-right: 6px; } @@ -289,10 +446,340 @@ .check-tree-node::before { content: "\2514"; - color: black; + color: #94a3b8; display: inline-block; margin-right: 6px; } + + .malware-summary { + box-sizing: border-box; + width: calc(100% - 32px); + margin: 16px; + border: 1px solid #f2b6a6; + border-radius: 8px; + background: + linear-gradient(135deg, rgba(255, 244, 239, 0.98), rgba(255, 255, 255, 0.98) 46%, rgba(240, 250, 248, 0.98)), + repeating-linear-gradient(135deg, rgba(190, 18, 60, 0.05) 0 2px, transparent 2px 12px); + box-shadow: 0 10px 28px rgba(108, 35, 28, 0.13); + color: #2f201f; + overflow: hidden; + } + + .malware-summary * { + box-sizing: border-box; + } + + .malware-summary-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + background: linear-gradient(90deg, #7f1d1d, #b91c1c 48%, #0f766e); + color: white; + } + + .malware-summary-header > div:first-child { + width: auto; + min-width: 0; + flex: 1 1 auto; + } + + .malware-summary-title { + width: auto; + font-size: 23px; + font-weight: 800; + line-height: 1.2; + } + + .malware-summary-reason { + width: auto; + max-width: 920px; + margin-top: 6px; + color: #ffe7df; + font-size: 15px; + line-height: 1.45; + } + + .malware-verdict-badge { + width: auto; + flex: 0 0 auto; + padding: 8px 12px; + border: 1px solid rgba(255, 255, 255, 0.45); + border-radius: 999px; + background: rgba(255, 255, 255, 0.17); + color: #fff7ed; + font-size: 13px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; + } + + .malware-summary-grid { + display: grid; + grid-template-columns: minmax(240px, 0.9fr) minmax(360px, 1.6fr); + gap: 16px; + padding: 16px; + } + + .malware-panel { + width: auto; + border: 1px solid #f2d1c5; + border-radius: 8px; + background: rgba(255, 255, 255, 0.86); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75); + overflow: hidden; + } + + .malware-summary-grid > .malware-panel:only-child { + grid-column: 1 / -1; + } + + .malware-panel-wide { + margin: 0 16px 16px; + } + + .malware-panel-title { + width: auto; + padding: 12px 14px; + background: #fff7ed; + color: #7f1d1d; + font-size: 14px; + font-weight: 800; + text-transform: uppercase; + } + + .malware-panel-body { + padding: 14px; + } + + .malware-stat { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: auto; + margin-bottom: 12px; + color: #4b2c28; + font-size: 14px; + } + + .malware-stat strong { + width: auto; + font-size: 22px; + color: #991b1b; + } + + .malware-meter { + width: 100%; + height: 13px; + border-radius: 999px; + background: #fee2e2; + border: 1px solid #fecaca; + overflow: hidden; + } + + .malware-meter-fill { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #f97316, #dc2626); + } + + .malware-link { + display: block; + margin-top: 12px; + padding: 10px 12px; + border-radius: 8px; + background: #eff6ff; + border: 1px solid #bfdbfe; + color: #075985; + font-weight: 700; + overflow-wrap: anywhere; + } + + .malware-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 12px; + } + + .malware-card { + width: auto; + min-height: 132px; + padding: 14px; + border: 1px solid #fde2d2; + border-left: 5px solid #dc2626; + border-radius: 8px; + background: linear-gradient(180deg, #ffffff, #fff7ed); + } + + .malware-card-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: auto; + margin-bottom: 8px; + color: #7f1d1d; + font-size: 16px; + font-weight: 800; + } + + .malware-rule-count { + width: auto; + padding: 4px 8px; + border-radius: 999px; + background: #fee2e2; + color: #991b1b; + font-size: 12px; + font-weight: 800; + white-space: nowrap; + } + + .malware-card p { + width: auto; + margin-bottom: 10px; + color: #51302a; + line-height: 1.4; + } + + .malware-evidence-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + width: auto; + padding: 0; + list-style: none; + } + + .malware-evidence-list li { + max-width: 100%; + padding: 5px 8px; + border-radius: 999px; + background: #f8fafc; + border: 1px solid #cbd5e1; + color: #334155; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 12px; + overflow-wrap: anywhere; + } + + .malware-evidence-list li.malware-evidence-more { + width: 100%; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + flex-basis: 100%; + } + + .malware-evidence-more details { + width: auto; + } + + .malware-evidence-more summary { + display: inline-block; + width: auto; + padding: 5px 9px; + border: 1px solid #99d5cd; + border-radius: 999px; + background: #ecfdf5; + color: #0f766e; + cursor: pointer; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 12px; + font-weight: 800; + list-style: none; + } + + .malware-evidence-more summary::-webkit-details-marker { + display: none; + } + + .malware-evidence-more summary::before { + content: "\25B8"; + display: inline-block; + margin-right: 5px; + transition: transform 0.12s ease-in-out; + } + + .malware-evidence-more details[open] summary { + margin-bottom: 8px; + background: #0f766e; + color: white; + } + + .malware-evidence-more details[open] summary::before { + transform: rotate(90deg); + } + + .malware-evidence-list-extra { + margin-top: 0; + } + + .malware-heuristic-list, + .malware-action-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 10px; + width: auto; + padding: 0; + list-style: none; + } + + .malware-heuristic-list li, + .malware-action-list li { + padding: 12px; + border-radius: 8px; + border: 1px solid #d9e5e2; + background: #ffffff; + color: #2f3b3a; + line-height: 1.4; + } + + .malware-heuristic-list strong { + display: block; + width: auto; + margin-bottom: 4px; + color: #0f766e; + } + + .malware-action-list li { + border-left: 5px solid #0f766e; + background: linear-gradient(90deg, #ecfdf5, #ffffff); + } + + @media (max-width: 850px) { + #report_title, + .report-section, + .malware-summary { + width: calc(100% - 20px); + margin: 10px; + } + + .report-section { + padding: 12px; + } + + .table_caption { + font-size: 20px; + } + + .malware-summary-header, + .malware-summary-grid { + display: block; + } + + .malware-verdict-badge { + display: inline-block; + margin-top: 12px; + } + + .malware-panel { + margin-top: 12px; + } + } diff --git a/src/macaron/output_reporter/templates/macaron.html b/src/macaron/output_reporter/templates/macaron.html index a20f4edd7..11092283e 100644 --- a/src/macaron/output_reporter/templates/macaron.html +++ b/src/macaron/output_reporter/templates/macaron.html @@ -128,6 +128,120 @@ {% endcall %} {% endmacro %} +{% macro render_malware_summary(malware_summary) %} +{% set confidence_percent = (malware_summary.confidence * 100) | round | int if malware_summary.confidence is not none else none %} +
+
+
+
Malware finding summary
+
{{ malware_summary.primary_reason }}
+
+
{{ malware_summary.verdict | replace("_", " ") | capitalize }}
+
+ +
+
+
Risk snapshot
+
+
+ Confidence + + {% if confidence_percent is not none %} + {{ confidence_percent }}% + {% else %} + N/A + {% endif %} + +
+
+ {% if confidence_percent is not none %} +
+ {% else %} +
+ {% endif %} +
+
+ Behavior groups + {{ malware_summary.behavioral_findings | length }} +
+
+ Failed heuristics + {{ malware_summary.failed_heuristics | length }} +
+ {% if malware_summary.known_malware %} + {{ render_hyperlink(href=malware_summary.known_malware, display_text="Open malware advisory", class="malware-link") }} + {% endif %} +
+
+ + {% if malware_summary.behavioral_findings %} +
+
Static analysis evidence
+
+
+ {% for finding in malware_summary.behavioral_findings %} +
+
+ {{ finding.category }} + {{ finding.rules | length }} rule{{ "s" if finding.rules | length != 1 else "" }} +
+

{{ finding.reason }}

+
    + {% for detection in finding.evidence[:5] %} +
  • {{ detection["file"] }}:{{ detection["start"] }}
  • + {% endfor %} + {% if finding.evidence | length > 5 %} +
  • +
    + {{ finding.evidence | length - 5 }} more finding{{ "s" if finding.evidence | length - 5 != 1 else "" }} +
      + {% for detection in finding.evidence[5:] %} +
    • {{ detection["file"] }}:{{ detection["start"] }}
    • + {% endfor %} +
    +
    +
  • + {% endif %} +
+
+ {% endfor %} +
+
+
+ {% endif %} +
+ + {% if malware_summary.failed_heuristics %} +
+
Why Macaron flagged this package
+
+
    + {% for heuristic in malware_summary.failed_heuristics %} +
  • + {{ heuristic.name }} + {{ heuristic.reason }} +
  • + {% endfor %} +
+
+
+ {% endif %} + + {% if malware_summary.recommended_actions %} +
+
Recommended actions
+
+
    + {% for action in malware_summary.recommended_actions %} +
  • {{ action }}
  • + {% endfor %} +
+
+
+ {% endif %} +
+{% endmacro %} + {% macro render_dep_summary(dep_summary_data) %} {%- set headers = dep_summary_data | get_headers -%} {% call render_table(header_names=headers, has_header=true, class="dep_summary") %} @@ -210,6 +324,7 @@ {% endblock %} {% block content %} +
Target information
{% if not target.info.remote_path %} {# @@ -222,9 +337,11 @@ {% endif %} {{ render_target_info_table(target.info) | indent(4) }} +
+
Provenance summary
{% if target.provenances.is_inferred == true %} @@ -234,12 +351,21 @@ {% endif %}
+
+
{{ render_tree_view_nested_list(target.provenances.content) | indent(4) }} +
+ {% if target.malware_summary %} + {{ render_malware_summary(target.malware_summary) | indent(4) }} + +
+ {% endif %} + {# When there is no passing check for this software component, we hide the check report table when the viewer first open the HTML file. However, the viewer could click the `toggler` to expand the table if they want to see it. To achieve this, there are two component we need to handle: @@ -248,6 +374,7 @@ If ``toggler-extend`` is not set, the toggler will be in the Collapsed state. - The ``check_report_content`` div, which contains the check report table: The HTML class ``hidden`` will set ``display: none`` to this div and hide the check report table. #} +
{% if metadata.has_passing_check %} {# Set the default state to Extend if there is a passing check. @@ -270,6 +397,7 @@ {{ render_checks_report(target.checks.results) | indent(8) }} +
@@ -280,6 +408,7 @@ - The status for each dependency #} {% if dependencies.analyzed_deps != 0 %} +
Dependency results
{{ dependencies.analyzed_deps }} dependencies that map to {{ dependencies.unique_dep_repos }} unique repositories have been successfully analyzed. @@ -287,5 +416,6 @@ {{ render_dep_summary(dependencies.checks_summary) | indent(4) }}
{{ render_dep_status(dependencies.dep_status) | indent(4) }} +
{% endif %} {% endblock %} diff --git a/src/macaron/policy_engine/policy_engine.py b/src/macaron/policy_engine/policy_engine.py index e815d48f4..72385f806 100644 --- a/src/macaron/policy_engine/policy_engine.py +++ b/src/macaron/policy_engine/policy_engine.py @@ -1,10 +1,11 @@ -# Copyright (c) 2023 - 2025, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2023 - 2026, Oracle and/or its affiliates. All rights reserved. # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. """This module handles invoking the souffle policy engine on a database.""" import logging import os +import re import sys from sqlalchemy import MetaData, create_engine, select @@ -22,6 +23,280 @@ logger: logging.Logger = logging.getLogger(__name__) +POLICY_REQUIREMENT_SENTINEL = 'policy_check_requirement("__macaron_no_policy__", "__macaron_no_check__").' + +STANDARD_POLICY_OUTPUTS = { + "passed_policies", + "failed_policies", + "component_satisfies_policy", + "component_violates_policy", +} + +POLICY_HEAD_RE = re.compile(r'Policy\s*\(\s*"(?P[^"]+)"', re.MULTILINE) +CHECK_REQUIREMENT_RE = re.compile( + r"check_(?:passed|failed)(?:_with_confidence)?\s*\([^,]+,\s*\"(?P[^\"]+)\"", + re.MULTILINE, +) +RULE_HEAD_RE = re.compile(r"(?P[A-Za-z_][A-Za-z0-9_]*)\s*\(") +RELATION_CALL_RE = re.compile(r"!?\s*(?P[A-Za-z_][A-Za-z0-9_]*)\s*\(") +IGNORED_REQUIREMENT_RELATIONS = { + "Policy", + "apply_policy_to", + "cat", + "check_failed", + "check_failed_with_confidence", + "check_passed", + "check_passed_with_confidence", + "component_violates_policy", + "is_component", + "is_repo", + "match", + "policy_applies_to", + "policy_check_requirement", + "to_string", + "transitive_dependency", +} + + +def _souffle_string(value: str) -> str: + """Return a Souffle string literal for a Python string.""" + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def _souffle_statements(policy_content: str) -> list[str]: + """Split Souffle content into statements, ignoring periods in strings and comments.""" + statements: list[str] = [] + current: list[str] = [] + in_string = False + escaped = False + in_line_comment = False + index = 0 + + rule_content = "\n".join( + line for line in policy_content.splitlines() if not line.lstrip().startswith((".", "#")) + ) + + while index < len(rule_content): + char = rule_content[index] + + if in_line_comment: + if char == "\n": + in_line_comment = False + current.append(char) + index += 1 + continue + + if ( + not in_string + and char == "/" + and index + 1 < len(rule_content) + and rule_content[index + 1] == "/" + ): + in_line_comment = True + index += 2 + continue + + if char == '"' and not escaped: + in_string = not in_string + escaped = in_string and char == "\\" and not escaped + + if char == "." and not in_string: + statement = "".join(current).strip() + if statement: + statements.append(statement) + current = [] + index += 1 + continue + + current.append(char) + index += 1 + + statement = "".join(current).strip() + if statement: + statements.append(statement) + + return statements + + +def _split_rule(statement: str) -> tuple[str, str] | None: + """Split a Souffle rule into head and body.""" + in_string = False + escaped = False + index = 0 + while index < len(statement) - 1: + char = statement[index] + if char == '"' and not escaped: + in_string = not in_string + escaped = in_string and char == "\\" and not escaped + + if not in_string and statement[index : index + 2] == ":-": + return statement[:index].strip(), statement[index + 2 :].strip() + index += 1 + + return None + + +def _build_rule_body_map(policy_content: str) -> dict[str, list[str]]: + """Build a map of relation names to their rule bodies.""" + rule_body_map: dict[str, list[str]] = {} + for statement in _souffle_statements(policy_content): + rule = _split_rule(statement) + if not rule: + continue + + head, body = rule + relation_match = RULE_HEAD_RE.match(head) + if not relation_match: + continue + + relation = relation_match.group("relation") + if relation in IGNORED_REQUIREMENT_RELATIONS: + continue + rule_body_map.setdefault(relation, []).append(body) + return rule_body_map + + +def _extract_check_requirements_from_body( + body: str, + rule_body_map: dict[str, list[str]], + visited_relations: set[str], +) -> set[str]: + """Extract literal check requirements from a rule body and helper relations it calls.""" + requirements = {check_match.group("check_id") for check_match in CHECK_REQUIREMENT_RE.finditer(body)} + + for relation_match in RELATION_CALL_RE.finditer(body): + relation = relation_match.group("relation") + if relation in IGNORED_REQUIREMENT_RELATIONS or relation in visited_relations: + continue + + helper_bodies = rule_body_map.get(relation) + if not helper_bodies: + continue + + visited_relations.add(relation) + for helper_body in helper_bodies: + requirements.update(_extract_check_requirements_from_body(helper_body, rule_body_map, visited_relations)) + visited_relations.remove(relation) + + return requirements + + +def policy_check_requirement_facts(policy_content: str) -> str: + """Build policy_check_requirement facts for literal checks used in Policy rules.""" + requirements: set[tuple[str, str]] = set() + rule_body_map = _build_rule_body_map(policy_content) + for statement in _souffle_statements(policy_content): + rule = _split_rule(statement) + if not rule: + continue + + head, body = rule + policy_match = POLICY_HEAD_RE.match(head) + if not policy_match: + continue + + policy_id = policy_match.group("policy_id") + for check_id in _extract_check_requirements_from_body(body, rule_body_map, set()): + requirements.add((policy_id, check_id)) + + facts = [POLICY_REQUIREMENT_SENTINEL] + facts.extend( + f"policy_check_requirement({_souffle_string(policy_id)}, {_souffle_string(check_id)})." + for policy_id, check_id in sorted(requirements) + ) + return "\n".join(facts) + + +def add_policy_check_requirements(policy_content: str) -> str: + """Append policy check requirement facts to a policy program.""" + return f"{policy_content.rstrip()}\n\n{policy_check_requirement_facts(policy_content)}\n" + + +def _format_policy_row(values: list[str], labels: list[str] | None = None) -> str: + """Format a policy output row for console display.""" + if labels and len(labels) == len(values): + return ", ".join(f"{label}: {value}" for label, value in zip(labels, values, strict=True)) + return " | ".join(values) + + +def _labels_for_evidence_relation(relation_name: str, values: list[str]) -> list[str] | None: + """Return friendly column labels for known policy evidence relations.""" + labels_by_relation = { + "malware_detection_findings": ["component", "failed check"], + "malware_component_violations": ["component", "failed check"], + "malware_dependency_violations": ["component", "dependency", "failed check"], + "malware_policy_violations": ["component", "dependency"], + "policy_component_check_failures": ["policy", "component", "failed check"], + "policy_dependency_check_failures": ["policy", "component", "dependency", "failed check"], + } + labels = labels_by_relation.get(relation_name) + if labels and len(labels) == len(values): + return labels + + lower_name = relation_name.lower() + if "dependency" in lower_name and len(values) == 3: + return ["component", "dependency", "evidence"] + if ("violation" in lower_name or "finding" in lower_name or "evidence" in lower_name) and len(values) == 2: + return ["component", "evidence"] + return None + + +def format_policy_results(results: dict[str, list[list[str]]]) -> str: + """Return a human-readable policy evaluation summary.""" + failed_policies = results.get("failed_policies", []) + passed_policies = results.get("passed_policies", []) + violating_components = results.get("component_violates_policy", []) + satisfying_components = results.get("component_satisfies_policy", []) + evidence_relations = { + relation: rows for relation, rows in results.items() if relation not in STANDARD_POLICY_OUTPUTS and rows + } + + lines = ["Policy evaluation summary"] + if failed_policies: + lines.append("Result: FAILED") + elif passed_policies: + lines.append("Result: PASSED") + else: + lines.append("Result: NO MATCHING POLICIES") + + lines.append("") + lines.append("Failed policies:") + if failed_policies: + lines.extend(f" - {row[0]}" for row in failed_policies) + else: + lines.append(" - None") + + lines.append("") + lines.append("Passed policies:") + if passed_policies: + lines.extend(f" - {row[0]}" for row in passed_policies) + else: + lines.append(" - None") + + lines.append("") + lines.append("Violating components:") + if violating_components: + for row in violating_components: + lines.append(f" - {_format_policy_row(row, ['component id', 'component', 'policy'])}") + else: + lines.append(" - None") + + if satisfying_components and not failed_policies: + lines.append("") + lines.append("Satisfying components:") + for row in satisfying_components: + lines.append(f" - {_format_policy_row(row, ['component id', 'component', 'policy'])}") + + if evidence_relations: + lines.append("") + lines.append("Evidence:") + for relation, rows in sorted(evidence_relations.items()): + lines.append(f" {relation}:") + for row in rows: + lines.append(f" - {_format_policy_row(row, _labels_for_evidence_relation(relation, row))}") + + return "\n".join(lines) + def get_generated(database_path: os.PathLike | str) -> SouffleProgram: """Get generated souffle code from database specified by configuration. @@ -174,15 +449,10 @@ def run_policy_engine(database_path: str, policy_content: str) -> dict: # TODO: uncomment the following line when the check is improved. # _check_version(database_path) - res = run_souffle(database_path, policy_content) - - output = [] - for key, values in res.items(): - output.append(str(key)) - for value in values: - output.append(f" {value}") + res = run_souffle(database_path, add_policy_check_requirements(policy_content)) - logger.info("Policy results:\n%s", "\n".join(output)) + logger.info("%s", format_policy_results(res)) + logger.debug("Raw policy results: %s", res) rich_handler = access_handler.get_handler() rich_handler.update_policy_engine(res) diff --git a/src/macaron/policy_engine/prelude/policy.dl b/src/macaron/policy_engine/prelude/policy.dl index 2e86f3e45..3dd8c1943 100644 --- a/src/macaron/policy_engine/prelude/policy.dl +++ b/src/macaron/policy_engine/prelude/policy.dl @@ -1,4 +1,4 @@ -/* Copyright (c) 2023 - 2024, Oracle and/or its affiliates. All rights reserved. */ +/* Copyright (c) 2023 - 2026, Oracle and/or its affiliates. All rights reserved. */ /* Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. */ /** @@ -75,7 +75,25 @@ component_violates_policy(component_id, purl, policy_id) :- is_component(component_id, purl), !Policy(policy_id, component_id, _). +.decl policy_check_requirement(policy_id: symbol, check_id: symbol) +.decl policy_component_check_failures(policy_id: symbol, component_purl: symbol, check_id: symbol) +.decl policy_dependency_check_failures(policy_id: symbol, component_purl: symbol, dependency_purl: symbol, check_id: symbol) + +policy_component_check_failures(policy_id, component_purl, check_id) :- + component_violates_policy(component_id, component_purl, policy_id), + policy_check_requirement(policy_id, check_id), + check_failed(component_id, check_id). + +policy_dependency_check_failures(policy_id, component_purl, dependency_purl, check_id) :- + component_violates_policy(component_id, component_purl, policy_id), + policy_check_requirement(policy_id, check_id), + transitive_dependency(component_id, dependency_id), + is_component(dependency_id, dependency_purl), + check_failed(dependency_id, check_id). + .output passed_policies .output failed_policies .output component_satisfies_policy .output component_violates_policy +.output policy_component_check_failures +.output policy_dependency_check_failures diff --git a/src/macaron/resources/policies/datalog/malware-detection-dependencies.dl.template b/src/macaron/resources/policies/datalog/malware-detection-dependencies.dl.template index 54b83bf27..5312eed0a 100644 --- a/src/macaron/resources/policies/datalog/malware-detection-dependencies.dl.template +++ b/src/macaron/resources/policies/datalog/malware-detection-dependencies.dl.template @@ -1,9 +1,13 @@ #include "prelude.dl" Policy("check-dependencies", component_id, "Check the dependencies of component.") :- - transitive_dependency(component_id, dependency), check_passed(component_id, "mcn_detect_malicious_metadata_1"), - check_passed(dependency, "mcn_detect_malicious_metadata_1"). + !malware_failed_dependency(component_id). + +.decl malware_failed_dependency(component_id: number) +malware_failed_dependency(component_id) :- + transitive_dependency(component_id, dependency), + check_failed(dependency, "mcn_detect_malicious_metadata_1"). apply_policy_to("check-dependencies", component_id) :- is_component(component_id, purl), diff --git a/src/macaron/slsa_analyzer/analyze_context.py b/src/macaron/slsa_analyzer/analyze_context.py index 93a3f48ba..597a4f0e1 100644 --- a/src/macaron/slsa_analyzer/analyze_context.py +++ b/src/macaron/slsa_analyzer/analyze_context.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022 - 2025, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2022 - 2026, Oracle and/or its affiliates. All rights reserved. # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. """This module contains the Analyze Context class. @@ -12,6 +12,7 @@ from typing import Any, TypedDict from macaron.database.table_definitions import Component, Provenance, SLSALevel +from macaron.output_reporter.malware_summary import build_malware_summary from macaron.repo_verifier.repo_verifier import RepositoryVerificationResult from macaron.slsa_analyzer.checks.check_result import CheckResult, CheckResultType from macaron.slsa_analyzer.ci_service.base_ci_service import BaseCIService @@ -233,9 +234,12 @@ def get_dict(self) -> dict: _sorted_on_id = sorted(self.check_results.values(), key=lambda item: item.check.check_id) # Remove result_tables since we don't have a good json representation for them. sorted_on_id = [] + malware_summary = None for res in _sorted_on_id: # res is CheckResult res_dict: dict = dict(res.get_summary()) + if malware_summary is None: + malware_summary = build_malware_summary(res) res_dict.pop("result_tables") sorted_on_id.append(res_dict) sorted_results = sorted(sorted_on_id, key=lambda item: item["result_type"], reverse=True) @@ -262,6 +266,8 @@ def get_dict(self) -> dict: }, "checks": {"summary": check_summary_sorted, "results": sorted_results}, } + if malware_summary: + result["malware_summary"] = malware_summary return result def get_check_summary(self) -> dict[CheckResultType, list[CheckResult]]: diff --git a/src/macaron/slsa_analyzer/database_store.py b/src/macaron/slsa_analyzer/database_store.py index 2ecbfe119..7d7cc2f58 100644 --- a/src/macaron/slsa_analyzer/database_store.py +++ b/src/macaron/slsa_analyzer/database_store.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023 - 2023, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2023 - 2026, Oracle and/or its affiliates. All rights reserved. # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. """The database_store module contains the methods to store analysis results to the database.""" @@ -13,6 +13,15 @@ logger: logging.Logger = logging.getLogger(__name__) +def get_policy_result_as_bool(check_result_type: CheckResultType) -> bool: + """Return the check result as a boolean for Datalog policy facts. + + Unknown checks are treated as passing in policy facts so policies only fail + on explicit check failures. + """ + return check_result_type in (CheckResultType.PASSED, CheckResultType.UNKNOWN) + + def store_analyze_context_to_db(analyze_ctx: AnalyzeContext) -> None: """Store the content of an analyzed context into the database. @@ -35,7 +44,7 @@ def store_analyze_context_to_db(analyze_ctx: AnalyzeContext) -> None: check_result_row = MappedCheckResult( check_id=check_result.check.check_id, component=analyze_ctx.component, - passed=check_result.result.result_type == CheckResultType.PASSED, + passed=get_policy_result_as_bool(check_result.result.result_type), ) for check_facts in check_result.result.result_tables: diff --git a/tests/output_reporter/test_malware_summary.py b/tests/output_reporter/test_malware_summary.py new file mode 100644 index 000000000..7bc74221b --- /dev/null +++ b/tests/output_reporter/test_malware_summary.py @@ -0,0 +1,177 @@ +# Copyright (c) 2026 - 2026, Oracle and/or its affiliates. All rights reserved. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. + +"""Tests for malware finding summaries in output reports.""" + +from dataclasses import dataclass +from typing import Any, cast + +from macaron.database.table_definitions import CheckFacts +from macaron.output_reporter.malware_summary import build_malware_summary +from macaron.slsa_analyzer.checks.check_result import ( + CheckInfo, + CheckResult, + CheckResultData, + CheckResultType, + Confidence, +) + + +@dataclass +class MalwareResultTable: + """Small test double for malicious metadata check fact rows.""" + + confidence: Confidence + known_malware: str | None + result: dict[str, str] + detail_information: dict[str, Any] + + +def malware_result_table( + *, + confidence: Confidence, + known_malware: str | None, + result: dict[str, str], + detail_information: dict[str, Any], +) -> CheckFacts: + """Build a typed malicious metadata result table test double.""" + return cast( + CheckFacts, + MalwareResultTable( + confidence=confidence, + known_malware=known_malware, + result=result, + detail_information=detail_information, + ), + ) + + +def test_build_malware_summary_groups_source_findings() -> None: + """Test source-code rule findings are grouped into user-facing behavior categories.""" + check_result = CheckResult( + check=CheckInfo( + check_id="mcn_detect_malicious_metadata_1", + check_description="Check if the package is malicious.", + eval_reqs=[], + ), + result=CheckResultData( + result_tables=[ + malware_result_table( + confidence=Confidence.LOW, + known_malware=None, + result={ + "source_code_repo": "FAIL", + "suspicious_patterns": "FAIL", + }, + detail_information={ + "enabled_sourcecode_rule_findings": { + "custom-rules.durabletask_ioc-c2-domain": { + "message": "Found a durabletask MAL-2026-4174 C2 domain indicator", + "detections": [ + { + "file": "durabletask-demo-1.4.2/durabletask/__init__.py", + "start": 25, + "end": 25, + } + ], + }, + "custom-rules.durabletask_ioc-systemd-persistence": { + "message": "Found pgsql-monitor.service persistence indicator", + "detections": [ + { + "file": "durabletask-demo-1.4.2/durabletask/__init__.py", + "start": 28, + "end": 28, + } + ], + }, + } + }, + ) + ], + result_type=CheckResultType.FAILED, + ), + ) + + summary = build_malware_summary(check_result) + + assert summary + assert summary["verdict"] == "suspicious" + assert summary["confidence"] == 0.4 + assert summary["primary_reason"] == "Source code contains suspicious Python malware patterns." + assert {heuristic["name"] for heuristic in summary["failed_heuristics"]} == { + "source_code_repo", + "suspicious_patterns", + } + assert {finding["category"] for finding in summary["behavioral_findings"]} == { + "Command and control", + "Persistence", + } + assert "Send the package artifact to dynamic analysis if available." in summary["recommended_actions"] + + +def test_build_malware_summary_known_malware() -> None: + """Test known-malware reports are summarized with the advisory link.""" + check_result = CheckResult( + check=CheckInfo( + check_id="mcn_detect_malicious_metadata_1", + check_description="Check if the package is malicious.", + eval_reqs=[], + ), + result=CheckResultData( + result_tables=[ + malware_result_table( + confidence=Confidence.HIGH, + known_malware="https://osv.dev/vulnerability/MAL-2026-4174", + result={}, + detail_information={"id": "MAL-2026-4174"}, + ) + ], + result_type=CheckResultType.FAILED, + ), + ) + + summary = build_malware_summary(check_result) + + assert summary + assert summary["verdict"] == "known_malware" + assert summary["known_malware"] == "https://osv.dev/vulnerability/MAL-2026-4174" + assert "MAL-2026-4174" in summary["primary_reason"] + + +def test_build_malware_summary_ignores_other_checks() -> None: + """Test summaries are only generated for the malicious metadata check.""" + check_result = CheckResult( + check=CheckInfo( + check_id="mcn_build_tool_1", + check_description="Check build tool.", + eval_reqs=[], + ), + result=CheckResultData(result_tables=[], result_type=CheckResultType.PASSED), + ) + + assert build_malware_summary(check_result) is None + + +def test_build_malware_summary_ignores_passing_malware_check() -> None: + """Test summaries are not generated for clean package reports.""" + check_result = CheckResult( + check=CheckInfo( + check_id="mcn_detect_malicious_metadata_1", + check_description="Check if the package is malicious.", + eval_reqs=[], + ), + result=CheckResultData( + result_tables=[ + malware_result_table( + confidence=Confidence.HIGH, + known_malware=None, + result={"suspicious_patterns": "PASS"}, + detail_information={}, + ) + ], + result_type=CheckResultType.PASSED, + ), + ) + + assert build_malware_summary(check_result) is None diff --git a/tests/policy_engine/test_existing_policy.py b/tests/policy_engine/test_existing_policy.py index 4d8a45af6..693de6ae4 100644 --- a/tests/policy_engine/test_existing_policy.py +++ b/tests/policy_engine/test_existing_policy.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 - 2025, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2025 - 2026, Oracle and/or its affiliates. All rights reserved. # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. """This module tests the existing-policy flag supported by the policy engine.""" @@ -11,6 +11,22 @@ from macaron.__main__ import verify_policy +def test_malware_dependency_policy_requires_no_failed_dependencies() -> None: + """Test malware dependency policy does not pass just because one dependency passes.""" + policy_path = Path( + "src", + "macaron", + "resources", + "policies", + "datalog", + "malware-detection-dependencies.dl.template", + ) + policy_content = policy_path.read_text(encoding="utf-8") + + assert "!malware_failed_dependency(component_id)." in policy_content + assert "check_passed(dependency, \"mcn_detect_malicious_metadata_1\")" not in policy_content + + def test_verify_existing_policy_success(tmp_path: Path) -> None: """When an existing policy is provided and package-url is valid, verify_policy returns EX_OK.""" db_file = os.path.join(tmp_path, "macaron.db") diff --git a/tests/policy_engine/test_policy.py b/tests/policy_engine/test_policy.py index b38346c22..1c6733135 100644 --- a/tests/policy_engine/test_policy.py +++ b/tests/policy_engine/test_policy.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022 - 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2022 - 2026, Oracle and/or its affiliates. All rights reserved. # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. """This module tests the policies supported by the policy engine.""" @@ -9,7 +9,13 @@ import pytest -from macaron.policy_engine.policy_engine import get_generated, run_souffle +from macaron.policy_engine.policy_engine import ( + add_policy_check_requirements, + format_policy_results, + get_generated, + policy_check_requirement_facts, + run_souffle, +) POLICY_DIR = Path(__file__).parent.joinpath("resources").joinpath("policies") POLICY_FILE = os.path.join(POLICY_DIR, "valid", "testpolicy.dl") @@ -35,26 +41,109 @@ def test_eval_policy(database_setup) -> None: # type: ignore # pylint: disable= with open(POLICY_FILE, encoding="utf-8") as file: policy_content = file.read() res = run_souffle(os.path.join(POLICY_FILE, DATABASE_FILE), policy_content) - assert res == { - "passed_policies": [["trusted_builder"]], - "component_satisfies_policy": [ - [ - "1", - "pkg:github.com/slsa-framework/slsa-verifier@fc50b662fcfeeeb0e97243554b47d9b20b14efac", - "trusted_builder", - ] - ], - "failed_policies": [["aggregate_l4"], ["aggregate_l2"]], - "component_violates_policy": [ - [ - "1", - "pkg:github.com/slsa-framework/slsa-verifier@fc50b662fcfeeeb0e97243554b47d9b20b14efac", - "aggregate_l4", + component_purl = "pkg:github.com/slsa-framework/slsa-verifier@fc50b662fcfeeeb0e97243554b47d9b20b14efac" + assert res["passed_policies"] == [["trusted_builder"]] + assert res["component_satisfies_policy"] == [["1", component_purl, "trusted_builder"]] + assert res["failed_policies"] == [["aggregate_l4"], ["aggregate_l2"]] + assert res["component_violates_policy"] == [ + ["1", component_purl, "aggregate_l4"], + ["1", component_purl, "aggregate_l2"], + ] + assert res["policy_component_check_failures"] == [] + assert res["policy_dependency_check_failures"] == [] + + +def test_policy_check_requirement_facts_extracts_literal_checks() -> None: + """Test Macaron generates policy check requirement facts from literal check rules.""" + policy_content = """ +Policy("github_actions_vulns", component_id, "GitHub Actions Vulnerability Detection") :- + check_passed(component_id, "mcn_githubactions_vulnerabilities_1"). + +Policy("malware_deps", component_id, "Malware Detection") :- + check_passed(component_id, "mcn_detect_malicious_metadata_1"), + check_passed(dependency, "mcn_detect_malicious_metadata_1"). +""" + + facts = policy_check_requirement_facts(policy_content) + + assert 'policy_check_requirement("__macaron_no_policy__", "__macaron_no_check__").' in facts + assert 'policy_check_requirement("github_actions_vulns", "mcn_githubactions_vulnerabilities_1").' in facts + assert 'policy_check_requirement("malware_deps", "mcn_detect_malicious_metadata_1").' in facts + + +def test_policy_check_requirement_facts_follows_helper_relations() -> None: + """Test policy check requirement facts include checks in helper relations.""" + policy_content = """ +#include "prelude.dl" + +Policy("custom-malware-policy", component_id, "Check malware dependencies.") :- + is_component(component_id, _), + !malware_flagged_dependency(component_id, _). + +.decl malware_flagged_dependency(parent: number, dependency_purl: symbol) +malware_flagged_dependency(parent, dependency_purl) :- + transitive_dependency(parent, dependency), + is_component(dependency, dependency_purl), + match("pkg:pypi/.*", dependency_purl), + check_failed(dependency, "mcn_detect_malicious_metadata_1"). +""" + + facts = policy_check_requirement_facts(policy_content) + + assert ( + 'policy_check_requirement("custom-malware-policy", "mcn_detect_malicious_metadata_1").' + in facts + ) + + +@pytest.mark.usefixtures("database_setup") +def test_policy_evidence_only_includes_policy_checks() -> None: + """Test policy evidence includes only checks required by the violated policy.""" + component_purl = "pkg:github.com/slsa-framework/slsa-verifier@fc50b662fcfeeeb0e97243554b47d9b20b14efac" + policy_content = f""" +#include "prelude.dl" + +Policy("check-malware", component_id, "Check malware detection.") :- + check_passed(component_id, "mcn_detect_malicious_metadata_1"). + +apply_policy_to("check-malware", component_id) :- + is_component(component_id, "{component_purl}"). +""" + + res = run_souffle(DATABASE_FILE, add_policy_check_requirements(policy_content)) + + assert res["failed_policies"] == [["check-malware"]] + assert res["policy_component_check_failures"] == [ + ["check-malware", component_purl, "mcn_detect_malicious_metadata_1"] + ] + + +def test_format_policy_results_includes_evidence() -> None: + """Test policy results are formatted with failed policies, components, and evidence.""" + summary = format_policy_results( + { + "passed_policies": [], + "failed_policies": [["check-dependencies"]], + "component_satisfies_policy": [], + "component_violates_policy": [ + ["1", "pkg:pypi/demo-internal-service@1.0.0", "check-dependencies"] ], - [ - "1", - "pkg:github.com/slsa-framework/slsa-verifier@fc50b662fcfeeeb0e97243554b47d9b20b14efac", - "aggregate_l2", + "policy_dependency_check_failures": [ + [ + "check-dependencies", + "pkg:pypi/demo-internal-service@1.0.0", + "pkg:pypi/durabletask-demo@1.4.2", + "mcn_detect_malicious_metadata_1", + ] ], - ], - } + } + ) + + assert "Result: FAILED" in summary + assert "Failed policies:\n - check-dependencies" in summary + assert ( + "policy: check-dependencies, " + "component: pkg:pypi/demo-internal-service@1.0.0, " + "dependency: pkg:pypi/durabletask-demo@1.4.2, " + "failed check: mcn_detect_malicious_metadata_1" + ) in summary diff --git a/tests/slsa_analyzer/test_database_store.py b/tests/slsa_analyzer/test_database_store.py new file mode 100644 index 000000000..0ce3808c1 --- /dev/null +++ b/tests/slsa_analyzer/test_database_store.py @@ -0,0 +1,14 @@ +# Copyright (c) 2026 - 2026, Oracle and/or its affiliates. All rights reserved. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. + +"""Tests for storing analysis results in Macaron's database.""" + +from macaron.slsa_analyzer.checks.check_result import CheckResultType +from macaron.slsa_analyzer.database_store import get_policy_result_as_bool + + +def test_unknown_check_result_is_policy_pass() -> None: + """Test UNKNOWN results are stored as passing for Datalog policy facts.""" + assert get_policy_result_as_bool(CheckResultType.PASSED) + assert get_policy_result_as_bool(CheckResultType.UNKNOWN) + assert not get_policy_result_as_bool(CheckResultType.FAILED)