diff --git a/.github/config/tool-manifest.json b/.github/config/tool-manifest.json index d81d09e..a53c007 100644 --- a/.github/config/tool-manifest.json +++ b/.github/config/tool-manifest.json @@ -50,6 +50,56 @@ "env": "GOLANGCI_LINT_ENABLED", "output": "golangci-lint.sarif" }, + { + "id": "buf", + "env": "BUF_ENABLED", + "output": "buf.sarif" + }, + { + "id": "sqlfluff", + "env": "SQLFLUFF_ENABLED", + "output": "sqlfluff.sarif" + }, + { + "id": "prisma-lint", + "env": "PRISMA_LINT_ENABLED", + "output": "prisma-lint.sarif" + }, + { + "id": "rubocop", + "env": "RUBOCOP_ENABLED", + "output": "rubocop.sarif" + }, + { + "id": "phpstan", + "env": "PHPSTAN_ENABLED", + "output": "phpstan.sarif" + }, + { + "id": "phpmd", + "env": "PHPMD_ENABLED", + "output": "phpmd.sarif" + }, + { + "id": "phpcs", + "env": "PHPCS_ENABLED", + "output": "phpcs.sarif" + }, + { + "id": "clippy", + "env": "CLIPPY_ENABLED", + "output": "clippy.sarif" + }, + { + "id": "detekt", + "env": "DETEKT_ENABLED", + "output": "detekt.sarif" + }, + { + "id": "swiftlint", + "env": "SWIFTLINT_ENABLED", + "output": "swiftlint.sarif" + }, { "id": "actionlint", "env": "ACTIONLINT_ENABLED", diff --git a/.github/scripts/buf_to_sarif.py b/.github/scripts/buf_to_sarif.py new file mode 100644 index 0000000..82ffaa5 --- /dev/null +++ b/.github/scripts/buf_to_sarif.py @@ -0,0 +1,67 @@ +import argparse +import json +import sys + +from sarif_converter_common import make_document, make_result, write_json_file + + +BUF_TOOL_ID = "buf" +BUF_TOOL_NAME = "Buf" +BUF_INFORMATION_URI = "https://buf.build/docs/lint/" + + +def convert_buf_json_lines(text, base_dir=".", cap=None): + results = [] + for line_number, line in enumerate(str(text or "").splitlines(), start=1): + if not line.strip(): + continue + try: + entry = json.loads(line) + except json.JSONDecodeError as err: + raise ValueError(f"invalid Buf JSON on line {line_number}") from err + if not isinstance(entry, dict): + raise ValueError(f"invalid Buf JSON on line {line_number}") + results.append(_entry_to_result(entry, base_dir=base_dir)) + return make_document(BUF_TOOL_NAME, BUF_TOOL_ID, results, information_uri=BUF_INFORMATION_URI, cap=cap) + + +def _entry_to_result(entry, base_dir="."): + rule_id = str(entry.get("type") or "buf").strip() or "buf" + message = str(entry.get("message") or rule_id) + return make_result( + rule_id, + "warning", + message, + str(entry.get("path") or "."), + line=entry.get("start_line"), + column=entry.get("start_column"), + end_line=entry.get("end_line"), + end_column=entry.get("end_column"), + base_dir=base_dir, + ) + + +def _main(argv): + parser = argparse.ArgumentParser(description="Convert Buf JSON-lines lint output to Sigilix SARIF.") + parser.add_argument("input") + parser.add_argument("output") + parser.add_argument("--base-dir", default=".") + parser.add_argument("--cap", type=int) + args = parser.parse_args(argv) + + try: + with open(args.input, encoding="utf-8") as handle: + content = handle.read() + except OSError: + content = "" + try: + document = convert_buf_json_lines(content, base_dir=args.base_dir, cap=args.cap) + except ValueError as err: + print(str(err), file=sys.stderr) + return 1 + write_json_file(args.output, document) + return 0 + + +if __name__ == "__main__": + sys.exit(_main(sys.argv[1:])) diff --git a/.github/scripts/buf_workflow_test.py b/.github/scripts/buf_workflow_test.py new file mode 100644 index 0000000..b110eb7 --- /dev/null +++ b/.github/scripts/buf_workflow_test.py @@ -0,0 +1,177 @@ +import json +import os +import re +import stat +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +WORKFLOW_PATH = os.path.join(ROOT, ".github", "workflows", "scan.yml") +MANIFEST_PATH = os.path.join(ROOT, ".github", "config", "tool-manifest.json") +SCRIPT_DIR = os.path.join(ROOT, ".github", "scripts") + + +class BufWorkflowTest(unittest.TestCase): + def read_file(self, path): + with open(path, encoding="utf-8") as handle: + return handle.read() + + def workflow_text(self): + return self.read_file(WORKFLOW_PATH) + + def workflow_input_block(self, input_name): + pattern = rf"\n {re.escape(input_name)}:\n(?P(?: .+\n)+)" + match = re.search(pattern, self.workflow_text()) + self.assertIsNotNone(match) + return match.group("block") + + def workflow_step_block(self, step_name): + pattern = rf"(?ms)^ - name: {re.escape(step_name)}\n.+?(?=^ - name: |\Z)" + match = re.search(pattern, self.workflow_text()) + self.assertIsNotNone(match) + return match.group(0) + + def manifest_rows(self): + with open(MANIFEST_PATH, encoding="utf-8") as handle: + return {row["id"]: row for row in json.load(handle)["tools"]} + + def script_text(self, filename): + return self.read_file(os.path.join(SCRIPT_DIR, filename)) + + def test_buf_is_default_on_and_manifested(self): + text = self.workflow_text() + rows = self.manifest_rows() + + self.assertIn(" default: true\n", self.workflow_input_block("buf")) + self.assertIn("BUF_ENABLED: ${{ inputs.buf }}", text) + self.assertIn('BUF_VERSION: "1.70.0"', text) + self.assertIn( + 'BUF_LINUX_X86_64_SHA256: "e2bbcdd324da09c16a15963dc2dae0525c955c05dc118223cf732f4f7509c5e6"', + text, + ) + self.assertEqual(rows["buf"], {"id": "buf", "env": "BUF_ENABLED", "output": "buf.sarif"}) + + def test_workflow_delegates_buf_and_oxlint_to_runner_scripts(self): + expectations = { + "Run Buf to SARIF": "run_buf.sh", + "Run Oxlint to SARIF": "run_oxlint.sh", + } + + for step_name, script_name in expectations.items(): + block = self.workflow_step_block(step_name) + self.assertIn(f'bash "$RUNNER_DIR/.github/scripts/{script_name}"', block) + + def test_buf_script_pins_binary_and_disambiguates_failures(self): + text = self.script_text("run_buf.sh") + + self.assertIn("BUF_VERSION", text) + self.assertIn("BUF_LINUX_X86_64_SHA256", text) + self.assertIn("buf-Linux-x86_64", text) + self.assertIn("unsupported runner platform", text) + self.assertIn("sha256sum -c --strict", text) + self.assertLess(text.index("sha256sum -c --strict"), text.index('"$buf_bin" --version')) + self.assertIn("No Protobuf files found", text) + self.assertIn("Using runner-owned Buf v2 MINIMAL config", text) + self.assertIn("mktemp -d", text) + self.assertIn("--config \"$buf_config\"", text) + self.assertIn("buf_to_sarif.py", text) + self.assertIn("sigilix_sarif_contract.py", text) + + def test_buf_wrapper_generates_temp_config_and_converts_findings(self): + with tempfile.TemporaryDirectory() as tmpdir: + source_dir = os.path.join(tmpdir, "source") + sarif_dir = os.path.join(tmpdir, "sarif") + runner_temp = os.path.join(tmpdir, "runner-temp") + bin_dir = os.path.join(tmpdir, "bin") + proto_dir = os.path.join(source_dir, "proto") + os.makedirs(proto_dir) + os.makedirs(sarif_dir) + os.makedirs(runner_temp) + os.makedirs(bin_dir) + with open(os.path.join(proto_dir, "bad.proto"), "w", encoding="utf-8") as handle: + handle.write('syntax = "proto3";\npackage foo;\n') + self.write_executable(os.path.join(bin_dir, "uname"), "#!/usr/bin/env bash\nif [ \"$1\" = \"-s\" ]; then echo Linux; else echo x86_64; fi\n") + self.write_executable( + os.path.join(bin_dir, "curl"), + "#!/usr/bin/env bash\nout=''\nwhile [ \"$#\" -gt 0 ]; do if [ \"$1\" = \"-o\" ]; then shift; out=\"$1\"; fi; shift || true; done\n" + "cat > \"$out\" <<'EOF'\n#!/usr/bin/env bash\nif [ \"$1\" = \"--version\" ]; then echo 1.70.0; exit 0; fi\n" + "printf '{\"path\":\"proto/bad.proto\",\"start_line\":2,\"start_column\":1,\"type\":\"PACKAGE_DIRECTORY_MATCH\",\"message\":\"bad package\"}\\n'\n" + "exit 100\nEOF\nchmod +x \"$out\"\n", + ) + self.write_executable(os.path.join(bin_dir, "sha256sum"), "#!/usr/bin/env bash\ncat >/dev/null\nexit 0\n") + + result = subprocess.run( + ["bash", os.path.join(SCRIPT_DIR, "run_buf.sh")], + cwd=source_dir, + env={ + "PATH": bin_dir + os.pathsep + os.environ.get("PATH", "/usr/bin:/bin"), + "BUF_LINUX_X86_64_SHA256": "e2bbcdd324da09c16a15963dc2dae0525c955c05dc118223cf732f4f7509c5e6", + "BUF_VERSION": "1.70.0", + "RESULT_CAP": "500", + "RUNNER_DIR": ROOT, + "RUNNER_TEMP": runner_temp, + "SARIF_DIR": sarif_dir, + "SOURCE_DIR": source_dir, + }, + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertIn("Using runner-owned Buf v2 MINIMAL config", result.stdout) + with open(os.path.join(sarif_dir, "buf.sarif"), encoding="utf-8") as handle: + document = json.load(handle) + + result = document["runs"][0]["results"][0] + self.assertEqual(result["ruleId"], "PACKAGE_DIRECTORY_MATCH") + self.assertEqual(result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"], "proto/bad.proto") + + def write_executable(self, path, content): + with open(path, "w", encoding="utf-8") as handle: + handle.write(textwrap.dedent(content)) + os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR) + + +class BufConverterTest(unittest.TestCase): + def assert_sigilix_properties(self, document): + self.assertEqual(document["version"], "2.1.0") + self.assertEqual(len(document["runs"]), 1) + properties = document["runs"][0]["tool"]["driver"]["properties"] + self.assertEqual(properties["sigilixToolId"], "buf") + self.assertEqual(properties["sigilixSource"], "deterministic-tool") + + def test_buf_json_lines_convert_to_sarif(self): + from buf_to_sarif import convert_buf_json_lines + + document = convert_buf_json_lines( + '{"path":"proto/bad.proto","start_line":3,"start_column":9,' + '"end_line":3,"end_column":17,"type":"MESSAGE_PASCAL_CASE",' + '"message":"Message name should be PascalCase."}\n' + '{"path":"proto/bad.proto","start_line":4,"start_column":10,' + '"type":"FIELD_LOWER_SNAKE_CASE","message":"Field should be lower_snake_case."}\n', + base_dir="/repo", + ) + + self.assert_sigilix_properties(document) + results = document["runs"][0]["results"] + self.assertEqual([result["ruleId"] for result in results], ["MESSAGE_PASCAL_CASE", "FIELD_LOWER_SNAKE_CASE"]) + self.assertEqual([result["level"] for result in results], ["warning", "warning"]) + self.assertEqual(results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"], "proto/bad.proto") + self.assertEqual( + results[0]["locations"][0]["physicalLocation"]["region"], + {"startLine": 3, "startColumn": 9, "endLine": 3, "endColumn": 17}, + ) + + def test_buf_converter_rejects_invalid_json_lines(self): + from buf_to_sarif import convert_buf_json_lines + + with self.assertRaises(ValueError): + convert_buf_json_lines('{"path":"ok.proto"}\nnot-json\n', base_dir="/repo") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/high_impact_language_tools_test.py b/.github/scripts/high_impact_language_tools_test.py new file mode 100644 index 0000000..f13738c --- /dev/null +++ b/.github/scripts/high_impact_language_tools_test.py @@ -0,0 +1,203 @@ +import json +import os +import re +import unittest + + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +WORKFLOW_PATH = os.path.join(ROOT, ".github", "workflows", "scan.yml") +MANIFEST_PATH = os.path.join(ROOT, ".github", "config", "tool-manifest.json") +SCRIPT_DIR = os.path.join(ROOT, ".github", "scripts") + +HIGH_IMPACT_OUTPUTS = { + "sqlfluff": "sqlfluff.sarif", + "prisma-lint": "prisma-lint.sarif", + "rubocop": "rubocop.sarif", + "phpstan": "phpstan.sarif", + "phpmd": "phpmd.sarif", + "phpcs": "phpcs.sarif", + "clippy": "clippy.sarif", + "detekt": "detekt.sarif", + "swiftlint": "swiftlint.sarif", +} + + +class HighImpactLanguageToolsWorkflowTest(unittest.TestCase): + def read_file(self, path): + with open(path, encoding="utf-8") as handle: + return handle.read() + + def workflow_text(self): + return self.read_file(WORKFLOW_PATH) + + def workflow_input_block(self, input_name): + pattern = rf"\n {re.escape(input_name)}:\n(?P(?: .+\n)+)" + match = re.search(pattern, self.workflow_text()) + self.assertIsNotNone(match) + return match.group("block") + + def workflow_step_block(self, step_name): + pattern = rf"(?ms)^ - name: {re.escape(step_name)}\n.+?(?=^ - name: |\Z)" + match = re.search(pattern, self.workflow_text()) + self.assertIsNotNone(match) + return match.group(0) + + def manifest_rows(self): + with open(MANIFEST_PATH, encoding="utf-8") as handle: + return {row["id"]: row for row in json.load(handle)["tools"]} + + def script_text(self, filename): + return self.read_file(os.path.join(SCRIPT_DIR, filename)) + + def test_high_impact_tools_are_default_on_and_manifested(self): + text = self.workflow_text() + rows = self.manifest_rows() + + for tool_id, output in HIGH_IMPACT_OUTPUTS.items(): + env_var = tool_id.upper().replace("-", "_") + "_ENABLED" + self.assertIn(" default: true\n", self.workflow_input_block(tool_id)) + self.assertIn(f"{env_var}: ${{{{ inputs.{tool_id} }}}}", text) + self.assertEqual(rows[tool_id], {"id": tool_id, "env": env_var, "output": output}) + + for version in ( + 'SQLFLUFF_VERSION: "4.2.1"', + 'PRISMA_LINT_VERSION: "0.13.1"', + 'RUBOCOP_VERSION: "1.86.2"', + "PHPSTAN_VERSION:", + "PHPMD_VERSION:", + "PHPCS_VERSION:", + "DETEKT_VERSION:", + "SWIFTLINT_VERSION:", + ): + self.assertIn(version, text) + + def test_workflow_delegates_high_impact_tools_to_group_script(self): + block = self.workflow_step_block("Run high-impact language tools to SARIF") + + self.assertIn('bash "$RUNNER_DIR/.github/scripts/run_high_impact_language_tools.sh"', block) + for tool_id in HIGH_IMPACT_OUTPUTS: + env_var = tool_id.upper().replace("-", "_") + "_ENABLED" + self.assertIn(f"{env_var}: ${{{{ inputs.{tool_id} }}}}", block) + + def test_group_script_uses_safe_presence_and_config_gates(self): + text = self.script_text("run_high_impact_language_tools.sh") + + for tool_id in HIGH_IMPACT_OUTPUTS: + env_var = tool_id.upper().replace("-", "_") + "_ENABLED" + self.assertIn(env_var, text) + self.assertIn("find_sqlfluff_config", text) + self.assertIn("find_prisma_lint_config", text) + self.assertIn("find_phpstan_config", text) + self.assertIn("find_phpcs_config", text) + self.assertIn("Cargo.toml", text) + self.assertIn("swiftlint_linux_amd64.zip", text) + self.assertIn("detekt-cli-${DETEKT_VERSION}-all.jar", text) + self.assertIn("high_impact_to_sarif.py", text) + + +class HighImpactLanguageToolsConverterTest(unittest.TestCase): + def assert_sigilix_properties(self, document, tool_id): + self.assertEqual(document["version"], "2.1.0") + self.assertEqual(len(document["runs"]), 1) + properties = document["runs"][0]["tool"]["driver"]["properties"] + self.assertEqual(properties["sigilixToolId"], tool_id) + self.assertEqual(properties["sigilixSource"], "deterministic-tool") + + def test_sqlfluff_json_converts_to_sarif(self): + from high_impact_to_sarif import convert_sqlfluff + + document = convert_sqlfluff( + [ + { + "filepath": "/repo/query.sql", + "violations": [ + {"code": "AL04", "description": "Duplicate alias.", "line_no": 7, "line_pos": 5} + ], + } + ], + base_dir="/repo", + ) + + self.assert_sigilix_properties(document, "sqlfluff") + result = document["runs"][0]["results"][0] + self.assertEqual(result["ruleId"], "AL04") + self.assertEqual(result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"], "query.sql") + + def test_prisma_lint_json_converts_to_sarif(self): + from high_impact_to_sarif import convert_prisma_lint + + document = convert_prisma_lint( + { + "violations": [ + { + "ruleName": "model-name-pascal-case", + "message": "Model name should be PascalCase.", + "fileName": "/repo/schema.prisma", + "location": {"startLine": 1, "startColumn": 1, "endLine": 1, "endColumn": 10}, + } + ] + }, + base_dir="/repo", + ) + + self.assert_sigilix_properties(document, "prisma-lint") + self.assertEqual(document["runs"][0]["results"][0]["ruleId"], "model-name-pascal-case") + + def test_rubocop_json_converts_to_sarif(self): + from high_impact_to_sarif import convert_rubocop + + document = convert_rubocop( + { + "files": [ + { + "path": "/repo/app.rb", + "offenses": [ + { + "cop_name": "Lint/UselessAssignment", + "message": "Useless assignment.", + "severity": "warning", + "location": {"start_line": 3, "start_column": 7}, + } + ], + } + ] + }, + base_dir="/repo", + ) + + self.assert_sigilix_properties(document, "rubocop") + self.assertEqual(document["runs"][0]["results"][0]["ruleId"], "Lint/UselessAssignment") + + def test_php_and_swift_converters_cover_common_json_shapes(self): + from high_impact_to_sarif import convert_phpcs, convert_phpmd, convert_phpstan, convert_swiftlint + + phpstan = convert_phpstan({"files": {"/repo/src/App.php": {"messages": [{"message": "Bad type.", "line": 5, "identifier": "argument.type"}]}}}, base_dir="/repo") + phpcs = convert_phpcs({"files": {"/repo/src/App.php": {"messages": [{"message": "Missing visibility.", "source": "Squiz.Scope.MethodScope.Missing", "type": "ERROR", "line": 4, "column": 3}]}}}, base_dir="/repo") + phpmd = convert_phpmd({"files": [{"file": "/repo/src/App.php", "violations": [{"rule": "UnusedLocalVariable", "description": "Unused variable.", "beginLine": 8}]}]}, base_dir="/repo") + swiftlint = convert_swiftlint([{"file": "/repo/App.swift", "rule_id": "force_cast", "reason": "Force casts should be avoided.", "line": 9, "character": 12, "severity": "Warning"}], base_dir="/repo") + + self.assert_sigilix_properties(phpstan, "phpstan") + self.assert_sigilix_properties(phpcs, "phpcs") + self.assert_sigilix_properties(phpmd, "phpmd") + self.assert_sigilix_properties(swiftlint, "swiftlint") + self.assertEqual(phpstan["runs"][0]["results"][0]["ruleId"], "argument.type") + self.assertEqual(phpcs["runs"][0]["results"][0]["level"], "error") + self.assertEqual(phpmd["runs"][0]["results"][0]["ruleId"], "UnusedLocalVariable") + self.assertEqual(swiftlint["runs"][0]["results"][0]["ruleId"], "force_cast") + + def test_clippy_json_lines_convert_to_sarif(self): + from high_impact_to_sarif import convert_clippy_json_lines + + document = convert_clippy_json_lines( + '{"reason":"compiler-message","message":{"level":"warning","message":"called `unwrap()`",' + '"code":{"code":"clippy::unwrap_used"},"spans":[{"is_primary":true,"file_name":"src/lib.rs",' + '"line_start":2,"column_start":9,"line_end":2,"column_end":17}]}}\n', + base_dir="/repo", + ) + + self.assert_sigilix_properties(document, "clippy") + self.assertEqual(document["runs"][0]["results"][0]["ruleId"], "clippy::unwrap_used") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/high_impact_to_sarif.py b/.github/scripts/high_impact_to_sarif.py new file mode 100644 index 0000000..def6d8e --- /dev/null +++ b/.github/scripts/high_impact_to_sarif.py @@ -0,0 +1,266 @@ +import argparse +import json +import sys + +from sarif_converter_common import load_json_file, make_document, make_result, write_json_file + + +TOOL_NAMES = { + "sqlfluff": "SQLFluff", + "prisma-lint": "Prisma Lint", + "rubocop": "RuboCop", + "phpstan": "PHPStan", + "phpmd": "PHPMD", + "phpcs": "PHPCS", + "clippy": "Clippy", + "swiftlint": "SwiftLint", +} +INFORMATION_URIS = { + "sqlfluff": "https://docs.sqlfluff.com/", + "prisma-lint": "https://github.com/loop-payments/prisma-lint", + "rubocop": "https://rubocop.org/", + "phpstan": "https://phpstan.org/", + "phpmd": "https://phpmd.org/", + "phpcs": "https://github.com/squizlabs/PHP_CodeSniffer", + "clippy": "https://github.com/rust-lang/rust-clippy", + "swiftlint": "https://realm.github.io/SwiftLint/", +} + + +def convert_sqlfluff(data, base_dir=".", cap=None): + results = [] + for file_entry in _list(data): + path = str(file_entry.get("filepath") or file_entry.get("file") or "") + for violation in _list(file_entry.get("violations")): + results.append( + make_result( + str(violation.get("code") or "sqlfluff"), + "warning", + str(violation.get("description") or violation.get("name") or ""), + path, + line=violation.get("line_no"), + column=violation.get("line_pos"), + base_dir=base_dir, + ) + ) + return _document("sqlfluff", results, cap=cap) + + +def convert_prisma_lint(data, base_dir=".", cap=None): + results = [] + for violation in _list(_dict(data).get("violations")): + location = _dict(violation.get("location")) + results.append( + make_result( + str(violation.get("ruleName") or "prisma-lint"), + "warning", + str(violation.get("message") or ""), + str(violation.get("fileName") or violation.get("file") or "."), + line=location.get("startLine"), + column=location.get("startColumn"), + end_line=location.get("endLine"), + end_column=location.get("endColumn"), + base_dir=base_dir, + ) + ) + return _document("prisma-lint", results, cap=cap) + + +def convert_rubocop(data, base_dir=".", cap=None): + results = [] + for file_entry in _list(_dict(data).get("files")): + path = str(file_entry.get("path") or "") + for offense in _list(file_entry.get("offenses")): + location = _dict(offense.get("location")) + results.append( + make_result( + str(offense.get("cop_name") or "rubocop"), + _level(str(offense.get("severity") or "")), + str(offense.get("message") or ""), + path, + line=location.get("start_line") or location.get("line"), + column=location.get("start_column") or location.get("column"), + base_dir=base_dir, + ) + ) + return _document("rubocop", results, cap=cap) + + +def convert_phpstan(data, base_dir=".", cap=None): + results = [] + for path, file_entry in _dict(_dict(data).get("files")).items(): + for message in _list(_dict(file_entry).get("messages")): + results.append( + make_result( + str(message.get("identifier") or "phpstan"), + "warning", + str(message.get("message") or ""), + path, + line=message.get("line"), + base_dir=base_dir, + ) + ) + return _document("phpstan", results, cap=cap) + + +def convert_phpcs(data, base_dir=".", cap=None): + results = [] + for path, file_entry in _dict(_dict(data).get("files")).items(): + for message in _list(_dict(file_entry).get("messages")): + results.append( + make_result( + str(message.get("source") or "phpcs"), + _level(str(message.get("type") or message.get("severity") or "")), + str(message.get("message") or ""), + path, + line=message.get("line"), + column=message.get("column"), + base_dir=base_dir, + ) + ) + return _document("phpcs", results, cap=cap) + + +def convert_phpmd(data, base_dir=".", cap=None): + results = [] + file_entries = _dict(data).get("files") if isinstance(data, dict) else data + for file_entry in _list(file_entries): + if not isinstance(file_entry, dict): + continue + path = str(file_entry.get("file") or file_entry.get("path") or "") + for violation in _list(file_entry.get("violations")): + results.append( + make_result( + str(violation.get("rule") or "phpmd"), + _phpmd_level(violation.get("priority")), + str(violation.get("description") or violation.get("message") or ""), + path, + line=violation.get("beginLine") or violation.get("line"), + end_line=violation.get("endLine"), + base_dir=base_dir, + ) + ) + return _document("phpmd", results, cap=cap) + + +def convert_swiftlint(data, base_dir=".", cap=None): + results = [] + for violation in _list(data): + results.append( + make_result( + str(violation.get("rule_id") or violation.get("rule") or "swiftlint"), + _level(str(violation.get("severity") or "")), + str(violation.get("reason") or violation.get("message") or ""), + str(violation.get("file") or "."), + line=violation.get("line"), + column=violation.get("character"), + base_dir=base_dir, + ) + ) + return _document("swiftlint", results, cap=cap) + + +def convert_clippy_json_lines(text, base_dir=".", cap=None): + results = [] + for line_number, line in enumerate(str(text or "").splitlines(), start=1): + if not line.strip(): + continue + try: + entry = json.loads(line) + except json.JSONDecodeError as err: + raise ValueError(f"invalid Clippy JSON on line {line_number}") from err + if _dict(entry).get("reason") != "compiler-message": + continue + message = _dict(entry.get("message")) + if not message: + continue + span = _primary_span(message) + if not span.get("file_name"): + continue + code = _dict(message.get("code")) + results.append( + make_result( + str(code.get("code") or "clippy"), + _level(str(message.get("level") or "")), + str(message.get("message") or ""), + str(span.get("file_name") or "."), + line=span.get("line_start"), + column=span.get("column_start"), + end_line=span.get("line_end"), + end_column=span.get("column_end"), + base_dir=base_dir, + ) + ) + return _document("clippy", results, cap=cap) + + +def _primary_span(message): + spans = _list(message.get("spans")) + for span in spans: + if span.get("is_primary") is True: + return span + return spans[0] if spans else {} + + +def _document(tool_id, results, cap=None): + return make_document(TOOL_NAMES[tool_id], tool_id, results, information_uri=INFORMATION_URIS[tool_id], cap=cap) + + +def _dict(value): + return value if isinstance(value, dict) else {} + + +def _list(value): + return value if isinstance(value, list) else [] + + +def _level(value): + value = value.lower() + if value in {"error", "fatal"}: + return "error" + if value in {"info", "note", "refactor", "convention"}: + return "note" + return "warning" + + +def _phpmd_level(priority): + if isinstance(priority, int) and priority <= 2: + return "error" + return "warning" + + +CONVERTERS = { + "sqlfluff": convert_sqlfluff, + "prisma-lint": convert_prisma_lint, + "rubocop": convert_rubocop, + "phpstan": convert_phpstan, + "phpmd": convert_phpmd, + "phpcs": convert_phpcs, + "swiftlint": convert_swiftlint, +} + + +def _main(argv): + parser = argparse.ArgumentParser(description="Convert high-impact language tool output to Sigilix SARIF.") + parser.add_argument("tool", choices=sorted([*CONVERTERS.keys(), "clippy"])) + parser.add_argument("input") + parser.add_argument("output") + parser.add_argument("--base-dir", default=".") + parser.add_argument("--cap", type=int) + args = parser.parse_args(argv) + + try: + if args.tool == "clippy": + with open(args.input, encoding="utf-8") as handle: + document = convert_clippy_json_lines(handle.read(), base_dir=args.base_dir, cap=args.cap) + else: + document = CONVERTERS[args.tool](load_json_file(args.input), base_dir=args.base_dir, cap=args.cap) + except ValueError as err: + print(str(err), file=sys.stderr) + return 1 + write_json_file(args.output, document) + return 0 + + +if __name__ == "__main__": + sys.exit(_main(sys.argv[1:])) diff --git a/.github/scripts/oxlint_workflow_test.py b/.github/scripts/oxlint_workflow_test.py index 4ceca98..bd371c6 100644 --- a/.github/scripts/oxlint_workflow_test.py +++ b/.github/scripts/oxlint_workflow_test.py @@ -7,6 +7,7 @@ WORKFLOW_PATH = os.path.join(os.path.dirname(__file__), "..", "workflows", "scan.yml") +SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "run_oxlint.sh") class OxlintWorkflowRuntimeTest(unittest.TestCase): @@ -14,8 +15,12 @@ def workflow_text(self): with open(WORKFLOW_PATH, encoding="utf-8") as workflow: return workflow.read() + def script_text(self): + with open(SCRIPT_PATH, encoding="utf-8") as script: + return script.read() + def workflow_tarball_size_helper(self): - text = self.workflow_text() + text = self.script_text() match = re.search( r"(?m)^(?P[ \t]*)tarball_size\(\) \{\n" r"(?:(?P=indent)[ \t]+.*\n)*" @@ -26,8 +31,8 @@ def workflow_tarball_size_helper(self): return textwrap.dedent(match.group(0)) def workflow_oxlint_find_command(self): - text = self.workflow_text() - match = re.search(r'(?ms)^[ \t]+if ! (?Pfind -P .+? > "\$files_list"); then', text) + text = self.script_text() + match = re.search(r'(?ms)^[ \t]*if ! (?Pfind -P .+? > "\$files_list"); then', text) self.assertIsNotNone(match) return match.group("command") @@ -94,7 +99,7 @@ def test_generated_directory_filter_excludes_nested_outputs(self): self.assertEqual(selected, ["./src/app.ts"]) def test_tarball_size_guard_runs_before_integrity_checks(self): - text = self.workflow_text() + text = self.script_text() missing_guard = '[ ! -s "$oxlint_package" ]' size_guard = 'tarball_size "$oxlint_package"' diff --git a/.github/scripts/run_buf.sh b/.github/scripts/run_buf.sh new file mode 100755 index 0000000..1a87add --- /dev/null +++ b/.github/scripts/run_buf.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${BUF_LINUX_X86_64_SHA256:?}" +: "${BUF_VERSION:?}" +: "${RESULT_CAP:?}" +: "${RUNNER_DIR:?}" +: "${RUNNER_TEMP:?}" +: "${SARIF_DIR:?}" +: "${SOURCE_DIR:?}" + +SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd -P)" +RUNNER_DIR="$(cd "$RUNNER_DIR" && pwd -P)" + +json="$SARIF_DIR/buf.jsonl" +raw="$SARIF_DIR/buf.raw.sarif" +out="$SARIF_DIR/buf.sarif" +buf_bin="$RUNNER_TEMP/buf-${BUF_VERSION}" +files_list="" +buf_config="" +buf_config_dir="" +buf_path_args=() + +mkdir -p "$SARIF_DIR" "$RUNNER_TEMP" +files_list="$(mktemp "$RUNNER_TEMP/buf-files.XXXXXX")" + +cleanup_buf() { + rm -f "$files_list" "$buf_bin" "$json" "$raw" 2>/dev/null || true + if [ -n "$buf_config_dir" ]; then rm -rf "$buf_config_dir" 2>/dev/null || true; fi +} +trap cleanup_buf EXIT + +emit_empty_json() { + : > "$json" +} + +discover_proto_files() { + find -P . \ + \( -type d \( -name '.git' -o -name 'node_modules' -o -name 'dist' -o -name 'build' \ + -o -name 'coverage' -o -name '.next' -o -name 'out' -o -name 'vendor' \ + -o -name '.terraform' \) -prune \) -o \ + \( -type f -name '*.proto' -print0 \) +} + +write_default_buf_config() { + buf_config_dir="$(mktemp -d "$RUNNER_TEMP/buf-config.XXXXXX")" + buf_config="$buf_config_dir/buf.yaml" + cat > "$buf_config" <<'EOF' +version: v2 +lint: + use: + - MINIMAL +EOF +} + +install_buf() { + if [ "$(uname -s)" != "Linux" ] || [ "$(uname -m)" != "x86_64" ]; then + echo "::error::Buf unsupported runner platform: expected Linux x86_64." + return 64 + fi + if [[ ! "$BUF_VERSION" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]]; then + echo "::error::Buf version must be a pinned x.y.z version." + return 64 + fi + if [[ ! "$BUF_LINUX_X86_64_SHA256" =~ ^[0-9a-f]{64}$ ]]; then + echo "::error::Buf checksum must be a pinned SHA256 value." + return 64 + fi + if ! curl -fsSL -o "$buf_bin" \ + "https://github.com/bufbuild/buf/releases/download/v${BUF_VERSION}/buf-Linux-x86_64"; then + echo "::error::Buf download failed." + return 64 + fi + if ! printf '%s %s\n' "$BUF_LINUX_X86_64_SHA256" "$buf_bin" | sha256sum -c --strict -; then + echo "::error::Buf checksum mismatch." + return 64 + fi + chmod +x "$buf_bin" + if ! detected_version="$("$buf_bin" --version 2>/dev/null)"; then + echo "::error::Buf version check failed." + return 64 + fi + if [ "$detected_version" != "$BUF_VERSION" ]; then + echo "::error::Buf installed version mismatch: expected ${BUF_VERSION}, got '${detected_version:-unavailable}'." + return 64 + fi +} + +cd "$SOURCE_DIR" +if ! discover_proto_files > "$files_list"; then + echo "::error::Buf file discovery failed." + exit 64 +elif ! grep -qz . "$files_list"; then + echo "::notice::No Protobuf files found - emitting empty Buf SARIF run." + emit_empty_json +else + echo "::notice::Using runner-owned Buf v2 MINIMAL config." + write_default_buf_config + while IFS= read -r -d '' file; do + buf_path_args+=(--path "$file") + done < "$files_list" + + install_buf + set +e + "$buf_bin" lint --config "$buf_config" --error-format=json "${buf_path_args[@]}" > "$json" + buf_status=$? + set -e + if [ "$buf_status" -ne 0 ] && [ "$buf_status" -ne 100 ]; then + echo "::error::Buf lint failed with exit code ${buf_status}." + exit 64 + fi + if [ "$buf_status" -eq 100 ] && [ ! -s "$json" ]; then + echo "::warning::Buf exited with lint findings but produced no JSON output - emitting empty Buf SARIF run." + emit_empty_json + fi +fi + +python3 "$RUNNER_DIR/.github/scripts/buf_to_sarif.py" "$json" "$raw" \ + --base-dir "$SOURCE_DIR" --cap "$RESULT_CAP" +python3 "$RUNNER_DIR/.github/scripts/sigilix_sarif_contract.py" \ + buf "$raw" "$out" --cap "$RESULT_CAP" --ensure-run diff --git a/.github/scripts/run_high_impact_language_tools.sh b/.github/scripts/run_high_impact_language_tools.sh new file mode 100755 index 0000000..e577cab --- /dev/null +++ b/.github/scripts/run_high_impact_language_tools.sh @@ -0,0 +1,344 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${RESULT_CAP:?}" +: "${RUNNER_DIR:?}" +: "${RUNNER_TEMP:?}" +: "${SARIF_DIR:?}" +: "${SOURCE_DIR:?}" +: "${DETEKT_CLI_ALL_JAR_SHA256:?}" +: "${DETEKT_VERSION:?}" +: "${PHPCS_VERSION:?}" +: "${PHPMD_VERSION:?}" +: "${PHPSTAN_VERSION:?}" +: "${PRISMA_LINT_NPM_INTEGRITY:?}" +: "${PRISMA_LINT_VERSION:?}" +: "${RUBOCOP_VERSION:?}" +: "${SQLFLUFF_VERSION:?}" +: "${SWIFTLINT_LINUX_AMD64_SHA256:?}" +: "${SWIFTLINT_VERSION:?}" + +SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd -P)" +RUNNER_DIR="$(cd "$RUNNER_DIR" && pwd -P)" + +mkdir -p "$SARIF_DIR" "$RUNNER_TEMP" +high_impact_temp="$(mktemp -d "$RUNNER_TEMP/high-impact.XXXXXX")" +trap 'rm -rf "$high_impact_temp" 2>/dev/null || true' EXIT + +validate_bool() { + local name="$1" value="${2:-false}" + if [ "$value" != "true" ] && [ "$value" != "false" ]; then + echo "::error::Invalid boolean value for $name." + exit 64 + fi +} + +run_enabled_tool() { + local tool="$1" value="$2" function_name="$3" status + [ "$value" = "true" ] || return 0 + set +e + (set -euo pipefail; "$function_name") + status=$? + set -e + if [ "$status" -ne 0 ]; then + echo "::warning::$tool wrapper failed with exit code $status - emitting empty $tool SARIF run." + empty_tool "$tool" || true + fi +} + +empty_tool() { + local tool="$1" raw="$SARIF_DIR/$1.raw.sarif" out="$SARIF_DIR/$1.sarif" + printf '{"version":"2.1.0","runs":[]}' > "$raw" + python3 "$RUNNER_DIR/.github/scripts/sigilix_sarif_contract.py" \ + "$tool" "$raw" "$out" --cap "$RESULT_CAP" --ensure-run +} + +convert_tool() { + local tool="$1" input="$2" out="$SARIF_DIR/$1.sarif" + python3 "$RUNNER_DIR/.github/scripts/high_impact_to_sarif.py" \ + "$tool" "$input" "$out" --base-dir "$SOURCE_DIR" --cap "$RESULT_CAP" +} + +collect_files() { + local output="$1" + shift + find -P . \ + \( -type d \( -name '.git' -o -name 'node_modules' -o -name 'dist' -o -name 'build' \ + -o -name 'coverage' -o -name '.next' -o -name 'out' -o -name 'vendor' \ + -o -name '.terraform' -o -name 'target' \) -prune \) -o \ + \( -type f \( "$@" \) -print0 \) > "$output" +} + +has_files() { + grep -qz . "$1" +} + +find_sqlfluff_config() { + local config="$high_impact_temp/sqlfluff.cfg" + cat > "$config" <<'EOF' +[sqlfluff] +dialect = ansi +templater = raw +EOF + printf '%s\n' "$config" +} + +find_prisma_lint_config() { + local config + for config in .prismalintrc.json .prismalintrc.yaml .prismalintrc.yml; do + [ -f "$config" ] && printf '%s\n' "$config" && return + done + return 1 +} + +find_phpstan_config() { + local config="$high_impact_temp/phpstan.neon" + cat > "$config" <<'EOF' +parameters: + level: 5 + paths: + - . +EOF + printf '%s\n' "$config" +} + +find_phpcs_config() { + local config + for config in phpcs.xml phpcs.xml.dist; do + [ -f "$config" ] || continue + printf '%s\n' "$config" + return + done + return 1 +} + +find_rubocop_files() { + collect_files "$1" \ + -name '*.rb' -o -name '*.rake' -o -name '*.gemspec' -o -name '*.ru' \ + -o -name 'Gemfile' -o -name 'Rakefile' -o -name 'Capfile' -o -name 'Fastfile' \ + -o -name 'Podfile' -o -name 'Vagrantfile' +} + +ensure_php_tools() { + local name="$1" package="$2" + if ! command -v php >/dev/null 2>&1 || ! command -v composer >/dev/null 2>&1; then + echo "::warning::PHP or Composer unavailable - emitting empty PHP tool SARIF runs." + return 1 + fi + php_tools_dir="$high_impact_temp/php-${name}-tools" + [ -x "$php_tools_dir/vendor/bin/$name" ] && return 0 + mkdir -p "$php_tools_dir" + COMPOSER_HOME="$high_impact_temp/composer-home" composer --working-dir "$php_tools_dir" \ + config repositories.packagist composer https://repo.packagist.org >/dev/null + COMPOSER_HOME="$high_impact_temp/composer-home" composer --working-dir "$php_tools_dir" \ + --no-interaction --no-progress --no-plugins --no-scripts --quiet require \ + "$package" +} + +run_sqlfluff() { + local files_list="$high_impact_temp/sql-files" json="$high_impact_temp/sqlfluff.json" config venv py + collect_files "$files_list" -name '*.sql' || { echo "::warning::SQLFluff file discovery failed."; empty_tool sqlfluff; return; } + if ! has_files "$files_list"; then echo "::notice::No SQL files found - emitting empty SQLFluff SARIF run."; empty_tool sqlfluff; return; fi + config="$(find_sqlfluff_config || true)" + if [ -z "$config" ]; then echo "::warning::Unable to create SQLFluff config - emitting empty SQLFluff SARIF run."; empty_tool sqlfluff; return; fi + venv="$high_impact_temp/sqlfluff-${SQLFLUFF_VERSION}" + py="$venv/bin/python" + if ! python3 -m venv "$venv" \ + || ! "$py" -m pip install --quiet --disable-pip-version-check --index-url https://pypi.org/simple/ "sqlfluff==${SQLFLUFF_VERSION}"; then + echo "::warning::SQLFluff install failed - emitting empty SQLFluff SARIF run."; empty_tool sqlfluff; return + fi + mapfile -d '' files < "$files_list" + # Ignored codes match CodeRabbit's SQLFluff tool docs as of 2026-06-12. + "$py" -m sqlfluff lint --format json --nofail --config "$config" \ + --exclude-rules LT01,LT02,CP01,CP02,CP03,CV06,RF02,RF06,LXR,PRS,TMP \ + "${files[@]}" > "$json" || printf '[]' > "$json" + convert_tool sqlfluff "$json" +} + +run_prisma_lint() { + local files_list="$high_impact_temp/prisma-files" json="$high_impact_temp/prisma-lint.json" config install_dir package package_name bin + collect_files "$files_list" -name '*.prisma' || { echo "::warning::Prisma Lint file discovery failed."; empty_tool prisma-lint; return; } + if ! has_files "$files_list"; then echo "::notice::No Prisma files found - emitting empty Prisma Lint SARIF run."; empty_tool prisma-lint; return; fi + config="$(find_prisma_lint_config || true)" + if [ -z "$config" ]; then + if [ -f .prismalintrc.js ] || [ -f prismalint.config.js ]; then + echo "::notice::Prisma Lint JS configs are ignored by Sigilix runner - emitting empty Prisma Lint SARIF run." + else + echo "::notice::No Prisma Lint config found - emitting empty Prisma Lint SARIF run." + fi + empty_tool prisma-lint + return + fi + install_dir="$high_impact_temp/prisma-lint-${PRISMA_LINT_VERSION}" + mkdir -p "$install_dir" + if ! pack_json="$(npm pack --json --silent --pack-destination "$install_dir" --registry=https://registry.npmjs.org "prisma-lint@${PRISMA_LINT_VERSION}")"; then + echo "::warning::Prisma Lint package download failed - emitting empty Prisma Lint SARIF run."; empty_tool prisma-lint; return + fi + if ! package_name="$(PACK_JSON="$pack_json" python3 -c 'import json,os; print(json.loads(os.environ["PACK_JSON"])[0]["filename"])')"; then + echo "::warning::Prisma Lint package metadata invalid - emitting empty Prisma Lint SARIF run."; empty_tool prisma-lint; return + fi + package="$install_dir/${package_name##*/}" + actual="sha512-$(openssl dgst -sha512 -binary "$package" | openssl base64 -A)" + if [ "$actual" != "$PRISMA_LINT_NPM_INTEGRITY" ]; then + echo "::warning::Prisma Lint package integrity mismatch - emitting empty Prisma Lint SARIF run."; empty_tool prisma-lint; return + fi + if ! npm install --silent --prefix "$install_dir" --ignore-scripts --registry=https://registry.npmjs.org --no-audit --no-fund "$package" >/dev/null; then + echo "::warning::Prisma Lint install failed - emitting empty Prisma Lint SARIF run."; empty_tool prisma-lint; return + fi + bin="$install_dir/node_modules/.bin/prisma-lint" + mapfile -d '' files < "$files_list" + "$bin" -c "$config" -o json "${files[@]}" > "$json" || true + [ -s "$json" ] || printf '{"violations":[]}' > "$json" + convert_tool prisma-lint "$json" +} + +run_rubocop() { + local files_list="$high_impact_temp/rubocop-files" json="$high_impact_temp/rubocop.json" gem_home config + find_rubocop_files "$files_list" || { echo "::warning::RuboCop file discovery failed."; empty_tool rubocop; return; } + if ! has_files "$files_list"; then echo "::notice::No Ruby files found - emitting empty RuboCop SARIF run."; empty_tool rubocop; return; fi + if ! command -v ruby >/dev/null 2>&1 || ! command -v gem >/dev/null 2>&1; then + echo "::warning::Ruby or gem unavailable - emitting empty RuboCop SARIF run."; empty_tool rubocop; return + fi + gem_home="$high_impact_temp/rubocop-gems"; config="$high_impact_temp/rubocop.yml" + printf 'AllCops:\n NewCops: disable\n' > "$config" + if ! GEM_HOME="$gem_home" gem install --no-document --source https://rubygems.org --install-dir "$gem_home" rubocop -v "$RUBOCOP_VERSION" >/dev/null; then + echo "::warning::RuboCop install failed - emitting empty RuboCop SARIF run."; empty_tool rubocop; return + fi + mapfile -d '' files < "$files_list" + GEM_HOME="$gem_home" "$gem_home/bin/rubocop" --config "$config" --format json --out "$json" "${files[@]}" || true + [ -s "$json" ] || printf '{"files":[]}' > "$json" + convert_tool rubocop "$json" +} + +run_phpstan() { + local files_list="$high_impact_temp/php-files" json="$high_impact_temp/phpstan.json" config + collect_files "$files_list" -name '*.php' || { echo "::warning::PHPStan file discovery failed."; empty_tool phpstan; return; } + if ! has_files "$files_list"; then echo "::notice::No PHP files found - emitting empty PHPStan SARIF run."; empty_tool phpstan; return; fi + config="$(find_phpstan_config || true)" + if [ -z "$config" ]; then echo "::warning::Unable to create PHPStan config - emitting empty PHPStan SARIF run."; empty_tool phpstan; return; fi + ensure_php_tools phpstan "phpstan/phpstan:${PHPSTAN_VERSION}" || { empty_tool phpstan; return; } + "$php_tools_dir/vendor/bin/phpstan" analyse --configuration "$config" --error-format=json --no-progress > "$json" || true + [ -s "$json" ] || printf '{"files":{}}' > "$json" + convert_tool phpstan "$json" +} + +run_phpmd() { + local files_list="$high_impact_temp/phpmd-files" json="$high_impact_temp/phpmd.json" csv="" + collect_files "$files_list" -name '*.php' || { echo "::warning::PHPMD file discovery failed."; empty_tool phpmd; return; } + if ! has_files "$files_list"; then echo "::notice::No PHP files found - emitting empty PHPMD SARIF run."; empty_tool phpmd; return; fi + ensure_php_tools phpmd "phpmd/phpmd:${PHPMD_VERSION}" || { empty_tool phpmd; return; } + while IFS= read -r -d '' file; do + case "$file" in + *,*) continue ;; + esac + csv="${csv}${csv:+,}$file" + done < "$files_list" + if [ -z "$csv" ]; then echo "::notice::No comma-safe PHP files for PHPMD - emitting empty PHPMD SARIF run."; empty_tool phpmd; return; fi + "$php_tools_dir/vendor/bin/phpmd" "$csv" json unusedcode > "$json" || true + [ -s "$json" ] || printf '{"files":[]}' > "$json" + convert_tool phpmd "$json" +} + +run_phpcs() { + local files_list="$high_impact_temp/phpcs-files" json="$high_impact_temp/phpcs.json" config + collect_files "$files_list" -name '*.php' || { echo "::warning::PHPCS file discovery failed."; empty_tool phpcs; return; } + if ! has_files "$files_list"; then echo "::notice::No PHP files found - emitting empty PHPCS SARIF run."; empty_tool phpcs; return; fi + config="$(find_phpcs_config || true)" + if [ -z "$config" ]; then echo "::notice::No valid PHPCS config found - emitting empty PHPCS SARIF run."; empty_tool phpcs; return; fi + ensure_php_tools phpcs "squizlabs/php_codesniffer:${PHPCS_VERSION}" || { empty_tool phpcs; return; } + mapfile -d '' files < "$files_list" + "$php_tools_dir/vendor/bin/phpcs" --standard=PSR12 --report=json "${files[@]}" > "$json" || true + [ -s "$json" ] || printf '{"files":{}}' > "$json" + convert_tool phpcs "$json" +} + +run_clippy() { + local files_list="$high_impact_temp/rust-files" json="$high_impact_temp/clippy.jsonl" + collect_files "$files_list" -name '*.rs' || { echo "::warning::Clippy file discovery failed."; empty_tool clippy; return; } + if ! has_files "$files_list"; then echo "::notice::No Rust files found - emitting empty Clippy SARIF run."; empty_tool clippy; return; fi + if [ ! -f Cargo.toml ]; then echo "::notice::No Cargo.toml found - emitting empty Clippy SARIF run."; empty_tool clippy; return; fi + if ! command -v cargo >/dev/null 2>&1; then echo "::warning::Cargo unavailable - emitting empty Clippy SARIF run."; empty_tool clippy; return; fi + cargo clippy --message-format=json --all-targets --no-deps -- -D warnings > "$json" || true + if [ ! -s "$json" ] || ! grep -q '"compiler-message"' "$json"; then + echo "::warning::Clippy produced no compiler messages - emitting empty Clippy SARIF run." + empty_tool clippy + return + fi + convert_tool clippy "$json" +} + +run_detekt() { + local files_list="$high_impact_temp/kotlin-files" jar="$high_impact_temp/detekt-cli-${DETEKT_VERSION}-all.jar" raw="$SARIF_DIR/detekt.raw.sarif" config="$high_impact_temp/detekt.yml" inputs="" + collect_files "$files_list" -name '*.kt' -o -name '*.kts' || { echo "::warning::detekt file discovery failed."; empty_tool detekt; return; } + if ! has_files "$files_list"; then echo "::notice::No Kotlin files found - emitting empty detekt SARIF run."; empty_tool detekt; return; fi + if ! command -v java >/dev/null 2>&1; then echo "::warning::Java unavailable - emitting empty detekt SARIF run."; empty_tool detekt; return; fi + if ! curl -fsSL -o "$jar" "https://github.com/detekt/detekt/releases/download/v${DETEKT_VERSION}/detekt-cli-${DETEKT_VERSION}-all.jar"; then + echo "::warning::detekt download failed - emitting empty detekt SARIF run."; empty_tool detekt; return + fi + if ! printf '%s %s\n' "$DETEKT_CLI_ALL_JAR_SHA256" "$jar" | sha256sum -c --strict -; then + echo "::warning::detekt checksum mismatch - emitting empty detekt SARIF run."; empty_tool detekt; return + fi + cat > "$config" <<'EOF' +potential-bugs: + active: true +complexity: + active: true +style: + active: false +EOF + while IFS= read -r -d '' file; do + case "$file" in + *,*) continue ;; + esac + inputs="${inputs}${inputs:+,}$file" + done < "$files_list" + if [ -z "$inputs" ]; then echo "::notice::No comma-safe Kotlin files for detekt - emitting empty detekt SARIF run."; empty_tool detekt; return; fi + java -jar "$jar" --input "$inputs" --config "$config" \ + --excludes '**/.git/**,**/node_modules/**,**/dist/**,**/build/**,**/coverage/**,**/.next/**,**/out/**,**/vendor/**' \ + --report "sarif:$raw" || true + [ -s "$raw" ] || { empty_tool detekt; return; } + python3 "$RUNNER_DIR/.github/scripts/sigilix_sarif_contract.py" detekt "$raw" "$SARIF_DIR/detekt.sarif" --cap "$RESULT_CAP" --ensure-run +} + +run_swiftlint() { + local files_list="$high_impact_temp/swift-files" zip="$high_impact_temp/swiftlint_linux_amd64.zip" bin="$high_impact_temp/swiftlint" json="$high_impact_temp/swiftlint.json" config="$high_impact_temp/swiftlint.yml" + collect_files "$files_list" -name '*.swift' || { echo "::warning::SwiftLint file discovery failed."; empty_tool swiftlint; return; } + if ! has_files "$files_list"; then echo "::notice::No Swift files found - emitting empty SwiftLint SARIF run."; empty_tool swiftlint; return; fi + if [ "$(uname -s)" != "Linux" ] || [ "$(uname -m)" != "x86_64" ]; then echo "::warning::SwiftLint unsupported runner platform - emitting empty SwiftLint SARIF run."; empty_tool swiftlint; return; fi + if ! curl -fsSL -o "$zip" "https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/swiftlint_linux_amd64.zip"; then + echo "::warning::SwiftLint download failed - emitting empty SwiftLint SARIF run."; empty_tool swiftlint; return + fi + if ! printf '%s %s\n' "$SWIFTLINT_LINUX_AMD64_SHA256" "$zip" | sha256sum -c --strict -; then + echo "::warning::SwiftLint checksum mismatch - emitting empty SwiftLint SARIF run."; empty_tool swiftlint; return + fi + if ! unzip -q "$zip" swiftlint -d "$high_impact_temp"; then + echo "::warning::SwiftLint extract failed - emitting empty SwiftLint SARIF run."; empty_tool swiftlint; return + fi + chmod +x "$bin" + cat > "$config" <<'EOF' +# Disabled rules match CodeRabbit's SwiftLint tool docs as of 2026-06-12. +disabled_rules: + - trailing_whitespace + - line_length + - comment_spacing + - vertical_whitespace +EOF + "$bin" lint --config "$config" --reporter json > "$json" || true + [ -s "$json" ] || printf '[]' > "$json" + convert_tool swiftlint "$json" +} + +cd "$SOURCE_DIR" +for name in SQLFLUFF_ENABLED PRISMA_LINT_ENABLED RUBOCOP_ENABLED PHPSTAN_ENABLED PHPMD_ENABLED PHPCS_ENABLED CLIPPY_ENABLED DETEKT_ENABLED SWIFTLINT_ENABLED; do + validate_bool "$name" "${!name:-false}" +done + +run_enabled_tool sqlfluff "${SQLFLUFF_ENABLED:-false}" run_sqlfluff +run_enabled_tool prisma-lint "${PRISMA_LINT_ENABLED:-false}" run_prisma_lint +run_enabled_tool rubocop "${RUBOCOP_ENABLED:-false}" run_rubocop +run_enabled_tool phpstan "${PHPSTAN_ENABLED:-false}" run_phpstan +run_enabled_tool phpmd "${PHPMD_ENABLED:-false}" run_phpmd +run_enabled_tool phpcs "${PHPCS_ENABLED:-false}" run_phpcs +run_enabled_tool clippy "${CLIPPY_ENABLED:-false}" run_clippy +run_enabled_tool detekt "${DETEKT_ENABLED:-false}" run_detekt +run_enabled_tool swiftlint "${SWIFTLINT_ENABLED:-false}" run_swiftlint diff --git a/.github/scripts/run_oxlint.sh b/.github/scripts/run_oxlint.sh new file mode 100755 index 0000000..a9044aa --- /dev/null +++ b/.github/scripts/run_oxlint.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${OXLINT_LINUX_X64_GNU_INTEGRITY:?}" +: "${OXLINT_NPM_INTEGRITY:?}" +: "${OXLINT_VERSION:?}" +: "${RESULT_CAP:?}" +: "${RUNNER_DIR:?}" +: "${RUNNER_TEMP:?}" +: "${SARIF_DIR:?}" +: "${SOURCE_DIR:?}" + +SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd -P)" +RUNNER_DIR="$(cd "$RUNNER_DIR" && pwd -P)" + +raw="$SARIF_DIR/oxlint.raw.sarif" +out="$SARIF_DIR/oxlint.sarif" +oxlint_config="$RUNNER_DIR/.github/config/oxlint-sigilix.oxlintrc.jsonc" +oxlint_pack_dir="$RUNNER_TEMP/oxlint-pack" +oxlint_install_dir="$RUNNER_TEMP/oxlint-${OXLINT_VERSION}" +oxlint_package="$oxlint_pack_dir/oxlint-${OXLINT_VERSION}.tgz" +oxlint_binding_package="$oxlint_pack_dir/oxlint-binding-linux-x64-gnu-${OXLINT_VERSION}.tgz" +oxlint_bin="$oxlint_install_dir/node_modules/.bin/oxlint" +files_list="$RUNNER_TEMP/oxlint-files" + +mkdir -p "$SARIF_DIR" "$RUNNER_TEMP" + +sri_sha512() { printf 'sha512-%s' "$(openssl dgst -sha512 -binary "$1" | openssl base64 -A)"; } + +tarball_size() { + if [ ! -r "$1" ]; then + printf '0' + return + fi + wc -c < "$1" | tr -d '[:space:]' +} + +cd "$SOURCE_DIR" +if ! find -P . \ + \( -type d \( -name '.git' -o -name 'node_modules' -o -name 'dist' -o -name 'build' \ + -o -name 'coverage' -o -name '.next' -o -name 'out' \) -prune \) -o \ + \( -type f \( -name '*.js' -o -name '*.jsx' -o -name '*.mjs' -o -name '*.cjs' \ + -o -name '*.ts' -o -name '*.tsx' \) -print0 \) > "$files_list"; then + echo "::warning::oxlint file discovery failed - manifest will record missing output." +else + mapfile -d '' files < "$files_list" + if [ "${#files[@]}" -eq 0 ]; then + printf '{"version":"2.1.0","runs":[]}' > "$raw" + elif [ ! -f "$oxlint_config" ]; then + echo "::warning::oxlint Sigilix config missing at $oxlint_config - manifest will record missing output." + else + oxlint_can_scan=true + mkdir -p "$oxlint_pack_dir" "$oxlint_install_dir" + if ! npm pack --silent --pack-destination "$oxlint_pack_dir" \ + --registry=https://registry.npmjs.org \ + "oxlint@${OXLINT_VERSION}" "@oxlint/binding-linux-x64-gnu@${OXLINT_VERSION}" >/dev/null; then + echo "::warning::oxlint package download failed - manifest will record missing output." + oxlint_can_scan=false + elif [ ! -s "$oxlint_package" ] || [ ! -s "$oxlint_binding_package" ]; then + echo "::warning::oxlint package tarball missing after download - manifest will record missing output." + oxlint_can_scan=false + elif [ "$(tarball_size "$oxlint_package")" -le 1024 ] \ + || [ "$(tarball_size "$oxlint_binding_package")" -le 1024 ]; then + echo "::warning::oxlint package tarball at or below 1024 bytes after download - manifest will record missing output." + oxlint_can_scan=false + elif [ "$(sri_sha512 "$oxlint_package")" != "$OXLINT_NPM_INTEGRITY" ]; then + echo "::warning::oxlint package integrity mismatch - manifest will record missing output." + oxlint_can_scan=false + elif [ "$(sri_sha512 "$oxlint_binding_package")" != "$OXLINT_LINUX_X64_GNU_INTEGRITY" ]; then + echo "::warning::oxlint linux binding integrity mismatch - manifest will record missing output." + oxlint_can_scan=false + elif ! npm install --silent --prefix "$oxlint_install_dir" --ignore-scripts --omit=optional \ + --registry=https://registry.npmjs.org --no-audit --no-fund \ + "$oxlint_package" "$oxlint_binding_package"; then + echo "::warning::oxlint verified package install failed - manifest will record missing output." + oxlint_can_scan=false + elif [ ! -x "$oxlint_bin" ]; then + echo "::warning::oxlint binary missing after verified install - manifest will record missing output." + oxlint_can_scan=false + elif ! oxlint_version="$("$oxlint_bin" --version 2>/dev/null)"; then + oxlint_version="" + oxlint_can_scan=false + else + oxlint_detected_version="$(printf '%s\n' "$oxlint_version" | sed -nE 's/.*([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' | head -n 1)" + if [ "$oxlint_detected_version" != "$OXLINT_VERSION" ]; then + oxlint_can_scan=false + fi + fi + if [ "$oxlint_can_scan" = false ]; then + echo "::warning::oxlint version mismatch or unavailable: expected ${OXLINT_VERSION}, got '${oxlint_version:-unavailable}' - manifest will record missing output." + fi + if [ "$oxlint_can_scan" = true ]; then + if ! "$oxlint_bin" \ + --config "$oxlint_config" \ + --disable-nested-config \ + --no-ignore \ + -A all -D correctness \ + --format sarif \ + --no-error-on-unmatched-pattern \ + -- \ + "${files[@]}" > "$raw"; then + if [ ! -s "$raw" ]; then + echo "::warning::oxlint scan failed and produced no SARIF output - manifest will record missing output." + fi + fi + fi + fi + if [ -s "$raw" ]; then + python3 "$RUNNER_DIR/.github/scripts/sigilix_sarif_contract.py" \ + oxlint "$raw" "$out" --cap "$RESULT_CAP" --ensure-run \ + || echo "::warning::oxlint SARIF normalization failed - manifest will record missing output." + fi +fi diff --git a/.github/scripts/sigilix_sarif_contract.py b/.github/scripts/sigilix_sarif_contract.py index 30ab340..fd10958 100644 --- a/.github/scripts/sigilix_sarif_contract.py +++ b/.github/scripts/sigilix_sarif_contract.py @@ -17,6 +17,16 @@ "flake8", "knip", "golangci-lint", + "buf", + "sqlfluff", + "prisma-lint", + "rubocop", + "phpstan", + "phpmd", + "phpcs", + "clippy", + "detekt", + "swiftlint", "actionlint", "shellcheck", "gitleaks", @@ -50,6 +60,16 @@ "flake8": "Flake8", "knip": "Knip", "golangci-lint": "golangci-lint", + "buf": "Buf", + "sqlfluff": "SQLFluff", + "prisma-lint": "Prisma Lint", + "rubocop": "RuboCop", + "phpstan": "PHPStan", + "phpmd": "PHPMD", + "phpcs": "PHPCS", + "clippy": "Clippy", + "detekt": "detekt", + "swiftlint": "SwiftLint", "actionlint": "actionlint", "shellcheck": "ShellCheck", "gitleaks": "gitleaks", diff --git a/.github/scripts/sigilix_sarif_test.py b/.github/scripts/sigilix_sarif_test.py index d796d59..f5cc419 100644 --- a/.github/scripts/sigilix_sarif_test.py +++ b/.github/scripts/sigilix_sarif_test.py @@ -487,9 +487,22 @@ def test_contract_cli_attaches_metadata_for_new_native_language_tools(self): "oxlint": "oxlint.sarif", "ast-grep": "ast-grep.sarif", "golangci-lint": "golangci-lint.sarif", + "buf": "buf.sarif", "htmlhint": "htmlhint.sarif", } +HIGH_IMPACT_LANGUAGE_TOOL_OUTPUTS = { + "sqlfluff": "sqlfluff.sarif", + "prisma-lint": "prisma-lint.sarif", + "rubocop": "rubocop.sarif", + "phpstan": "phpstan.sarif", + "phpmd": "phpmd.sarif", + "phpcs": "phpcs.sarif", + "clippy": "clippy.sarif", + "detekt": "detekt.sarif", + "swiftlint": "swiftlint.sarif", +} + POLICY_TOOL_OUTPUTS = { "regal": "regal.sarif", } @@ -531,6 +544,7 @@ def test_contract_cli_attaches_metadata_for_new_native_language_tools(self): **POLICY_TOOL_OUTPUTS, **SAST_TOOL_OUTPUTS, **LANGUAGE_CONVERTER_TOOL_OUTPUTS, + **HIGH_IMPACT_LANGUAGE_TOOL_OUTPUTS, **CONFIG_TOOL_OUTPUTS, **CI_SECURITY_TOOL_OUTPUTS, **CONTAINER_TOOL_OUTPUTS, @@ -566,6 +580,11 @@ def config_text(self, name): with open(path, "r", encoding="utf-8") as handle: return handle.read() + def script_text(self, name): + path = os.path.join(os.path.dirname(__file__), name) + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + def test_static_tool_manifest_matches_known_outputs(self): manifest = self.raw_tool_manifest() @@ -683,6 +702,7 @@ def test_catalog_tool_outputs_are_manifested_and_merged(self): **POLICY_TOOL_OUTPUTS, **SAST_TOOL_OUTPUTS, **LANGUAGE_CONVERTER_TOOL_OUTPUTS, + **HIGH_IMPACT_LANGUAGE_TOOL_OUTPUTS, **CONFIG_TOOL_OUTPUTS, **CI_SECURITY_TOOL_OUTPUTS, **CONTAINER_TOOL_OUTPUTS, @@ -694,22 +714,23 @@ def test_catalog_tool_outputs_are_manifested_and_merged(self): self.assertEqual(rows[tool_id]["output"], output_name) def test_oxlint_is_default_on_and_empty_tree_emits_empty_sarif(self): - text = self.workflow_text() + workflow = self.workflow_text() + text = self.script_text("run_oxlint.sh") - self.assertRegex(text, r"\n oxlint:\n(?: [^\n]+\n)+? default: true\n") + self.assertRegex(workflow, r"\n oxlint:\n(?: [^\n]+\n)+? default: true\n") self.assertIn('files_list="$RUNNER_TEMP/oxlint-files"', text) - self.assertIn("find -P . \\\n \\( -type d \\( -name '.git' -o -name 'node_modules'", text) + self.assertIn("find -P . \\\n \\( -type d \\( -name '.git' -o -name 'node_modules'", text) self.assertIn("-name 'dist' -o -name 'build'", text) self.assertIn("-name '.next' -o -name 'out' \\) -prune \\) -o", text) self.assertIn('printf \'{"version":"2.1.0","runs":[]}\' > "$raw"', text) def test_oxlint_separates_options_from_file_paths(self): - text = self.workflow_text() + text = self.script_text("run_oxlint.sh") self.assertRegex(text, r"\n\s+-- \\\n\s+\"\$\{files\[@\]\}\" > \"\$raw\"") def test_oxlint_uses_sigilix_controlled_correctness_mode(self): - text = self.workflow_text() + text = self.script_text("run_oxlint.sh") config_text = self.config_text("oxlint-sigilix.oxlintrc.jsonc") self.assertIn("oxlint_config=\"$RUNNER_DIR/.github/config/oxlint-sigilix.oxlintrc.jsonc\"", text) @@ -728,7 +749,8 @@ def test_node_runtime_is_pinned_for_npx_tools(self): self.assertIn("check-latest: false", text) def test_oxlint_asserts_pinned_runtime_version_before_scan(self): - text = self.workflow_text() + workflow = self.workflow_text() + text = self.script_text("run_oxlint.sh") self.assertIn('npm pack --silent --pack-destination "$oxlint_pack_dir"', text) self.assertIn('npm install --silent --prefix "$oxlint_install_dir" --ignore-scripts --omit=optional', text) @@ -741,8 +763,8 @@ def test_oxlint_asserts_pinned_runtime_version_before_scan(self): self.assertIn("oxlint_detected_version=\"$(printf '%s\\n' \"$oxlint_version\"", text) self.assertIn('[ "$oxlint_detected_version" != "$OXLINT_VERSION" ]', text) self.assertIn("oxlint version mismatch or unavailable", text) - self.assertIn("OXLINT_NPM_INTEGRITY:", text) - self.assertIn("OXLINT_LINUX_X64_GNU_INTEGRITY:", text) + self.assertIn("OXLINT_NPM_INTEGRITY:", workflow) + self.assertIn("OXLINT_LINUX_X64_GNU_INTEGRITY:", workflow) self.assertIn('[ "$(sri_sha512 "$oxlint_package")" != "$OXLINT_NPM_INTEGRITY" ]', text) self.assertIn('[ "$(sri_sha512 "$oxlint_binding_package")" != "$OXLINT_LINUX_X64_GNU_INTEGRITY" ]', text) self.assertIn('[ ! -f "$oxlint_config" ]', text) @@ -761,6 +783,7 @@ def test_next_batch_tool_versions_are_pinned(self): "TFLINT_VERSION", "BIOME_VERSION", "OXLINT_VERSION", + "BUF_VERSION", "OPENGREP_VERSION", "BRAKEMAN_VERSION", ): diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db451e8..9700c43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,8 @@ jobs: python3 .github/scripts/biome_workflow_test.py python3 .github/scripts/docs_config_tools_test.py python3 .github/scripts/oxlint_workflow_test.py + python3 .github/scripts/buf_workflow_test.py + python3 .github/scripts/high_impact_language_tools_test.py python3 .github/scripts/ast_grep_workflow_test.py python3 .github/scripts/policy_iac_tools_workflow_test.py python3 .github/scripts/security_sast_tools_workflow_test.py diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index ce57408..c55bbe8 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -92,6 +92,47 @@ on: required: false default: true type: boolean + buf: + description: "Run Buf on Protobuf files and merge its SARIF." + required: false + default: true + type: boolean + sqlfluff: + description: "Run SQLFluff on SQL files and merge its SARIF." + default: true + type: boolean + prisma-lint: + description: "Run Prisma Lint on Prisma schema files and merge its SARIF." + default: true + type: boolean + rubocop: + description: "Run RuboCop on Ruby files and merge its SARIF." + default: true + type: boolean + phpstan: + description: "Run PHPStan on PHP files and merge its SARIF." + default: true + type: boolean + phpmd: + description: "Run PHPMD on PHP files and merge its SARIF." + default: true + type: boolean + phpcs: + description: "Run PHPCS on PHP files and merge its SARIF." + default: true + type: boolean + clippy: + description: "Run Clippy on Rust projects and merge its SARIF." + default: true + type: boolean + detekt: + description: "Run detekt on Kotlin files and merge its SARIF." + default: true + type: boolean + swiftlint: + description: "Run SwiftLint on Swift files and merge its SARIF." + default: true + type: boolean actionlint: description: "Run actionlint on GitHub Actions workflow files." required: false @@ -223,9 +264,13 @@ jobs: BRAKEMAN_GEM_SHA256: "7bf921fa9638544835df9aa7b3e720a9a72c0267f34f92135955edd80d4dcf6f" BIOME_VERSION: "2.4.16" BRAKEMAN_VERSION: "8.0.4" + BUF_LINUX_X86_64_SHA256: "e2bbcdd324da09c16a15963dc2dae0525c955c05dc118223cf732f4f7509c5e6" + BUF_VERSION: "1.70.0" CHECKMAKE_LINUX_AMD64_SHA256: "e2effb876913f3ee2caef0ba35f6202c5e8a3cd55a077d8d2b9ce2034257b6af" CHECKMAKE_VERSION: "0.3.2" CHECKOV_VERSION: "3.3.1" + DETEKT_CLI_ALL_JAR_SHA256: "2ce2ff952e150baf28a29cda70a363b0340b3e81a55f43e51ec5edffc3d066c1" + DETEKT_VERSION: "1.23.8" DETECT_LIBC_NPM_INTEGRITY: "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" DETECT_LIBC_VERSION: "2.1.2" DOTENV_LINTER_LINUX_X86_64_SHA256: "9e2f1f6eb88a71ac15ef209f78f5a68644db82f132d2677112b4f80ec2bd2127" @@ -250,18 +295,27 @@ jobs: OXLINT_LINUX_X64_GNU_INTEGRITY: "sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==" OXLINT_NPM_INTEGRITY: "sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw==" OXLINT_VERSION: "1.69.0" + PHPCS_VERSION: "4.0.1" + PHPMD_VERSION: "2.15.0" + PHPSTAN_VERSION: "2.2.2" + PRISMA_LINT_NPM_INTEGRITY: "sha512-GCq58O+E7KpqIgir8B160irZ57SBixo7R2JpS9A1NPOsWlxWFTxshg7z5wg5t2LEhAKYc1xW9ToiD7a6ICh0Qg==" + PRISMA_LINT_VERSION: "0.13.1" PYLINT_VERSION: "4.0.5" RACC_GEM_SHA256: "4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f" RACC_VERSION: "1.8.1" REGAL_LINUX_X86_64_SHA256: "6769dcd8e88bc5ba5ff4fac500e4a99d55b3eec3d1d0842833d84f6820a2a80f" REGAL_VERSION: "0.41.1" RESULT_CAP: ${{ inputs.result-cap }} + RUBOCOP_VERSION: "1.86.2" RUFF_VERSION: "0.15.17" SARIF_BYTE_CAP: ${{ inputs.sarif-byte-cap }} SEMGREP_VERSION: "1.166.0" SHELLCHECK_VERSION: "0.11.0" STYLELINT_NPM_INTEGRITY: "sha512-KIlzWXMHUvgfPUR0R7TK3H80yCIi0uoivUwf+6Az4yrHJD1Q3c1qIkh/H5Z0i/K3QXgtq/UMEkWyBUSUwnpnOg==" STYLELINT_VERSION: "17.12.0" + SQLFLUFF_VERSION: "4.2.1" + SWIFTLINT_LINUX_AMD64_SHA256: "26db741d43f2f2dc26c0cf16911100a3e186c3d1dbb59e55ad3ac87b0de4538f" + SWIFTLINT_VERSION: "0.63.3" TFLINT_LINUX_AMD64_SHA256: "8441a7d97df20431f19c9b9d27ff4c63e308c964e86660bc7cc0cf7bbe0725e8" TFLINT_VERSION: "0.63.1" TRIVY_VERSION: "0.71.0" @@ -398,6 +452,32 @@ jobs: cd "$SOURCE_DIR" bash "$RUNNER_DIR/.github/scripts/run_golangci_lint.sh" + - name: Run Buf to SARIF + if: ${{ inputs.buf }} + env: + BUF_ENABLED: ${{ inputs.buf }} + run: | + set -euo pipefail + cd "$SOURCE_DIR" + bash "$RUNNER_DIR/.github/scripts/run_buf.sh" + + - name: Run high-impact language tools to SARIF + if: ${{ inputs.sqlfluff || inputs.prisma-lint || inputs.rubocop || inputs.phpstan || inputs.phpmd || inputs.phpcs || inputs.clippy || inputs.detekt || inputs.swiftlint }} + env: + SQLFLUFF_ENABLED: ${{ inputs.sqlfluff }} + PRISMA_LINT_ENABLED: ${{ inputs.prisma-lint }} + RUBOCOP_ENABLED: ${{ inputs.rubocop }} + PHPSTAN_ENABLED: ${{ inputs.phpstan }} + PHPMD_ENABLED: ${{ inputs.phpmd }} + PHPCS_ENABLED: ${{ inputs.phpcs }} + CLIPPY_ENABLED: ${{ inputs.clippy }} + DETEKT_ENABLED: ${{ inputs.detekt }} + SWIFTLINT_ENABLED: ${{ inputs.swiftlint }} + run: | + set -euo pipefail + cd "$SOURCE_DIR" + bash "$RUNNER_DIR/.github/scripts/run_high_impact_language_tools.sh" + - name: Run actionlint to SARIF if: ${{ inputs.actionlint }} run: | @@ -756,98 +836,7 @@ jobs: run: | set -euo pipefail cd "$SOURCE_DIR" - raw="$SARIF_DIR/oxlint.raw.sarif" - out="$SARIF_DIR/oxlint.sarif" - oxlint_config="$RUNNER_DIR/.github/config/oxlint-sigilix.oxlintrc.jsonc" - oxlint_pack_dir="$RUNNER_TEMP/oxlint-pack" - oxlint_install_dir="$RUNNER_TEMP/oxlint-${OXLINT_VERSION}" - oxlint_package="$oxlint_pack_dir/oxlint-${OXLINT_VERSION}.tgz" - oxlint_binding_package="$oxlint_pack_dir/oxlint-binding-linux-x64-gnu-${OXLINT_VERSION}.tgz" - oxlint_bin="$oxlint_install_dir/node_modules/.bin/oxlint" - files_list="$RUNNER_TEMP/oxlint-files" - sri_sha512() { printf 'sha512-%s' "$(openssl dgst -sha512 -binary "$1" | openssl base64 -A)"; } - tarball_size() { - if [ ! -r "$1" ]; then - printf '0' - return - fi - wc -c < "$1" | tr -d '[:space:]' - } - if ! find -P . \ - \( -type d \( -name '.git' -o -name 'node_modules' -o -name 'dist' -o -name 'build' \ - -o -name 'coverage' -o -name '.next' -o -name 'out' \) -prune \) -o \ - \( -type f \( -name '*.js' -o -name '*.jsx' -o -name '*.mjs' -o -name '*.cjs' \ - -o -name '*.ts' -o -name '*.tsx' \) -print0 \) > "$files_list"; then - echo "::warning::oxlint file discovery failed - manifest will record missing output." - else - mapfile -d '' files < "$files_list" - if [ "${#files[@]}" -eq 0 ]; then - printf '{"version":"2.1.0","runs":[]}' > "$raw" - elif [ ! -f "$oxlint_config" ]; then - echo "::warning::oxlint Sigilix config missing at $oxlint_config - manifest will record missing output." - else - oxlint_can_scan=true - mkdir -p "$oxlint_pack_dir" "$oxlint_install_dir" - if ! npm pack --silent --pack-destination "$oxlint_pack_dir" \ - --registry=https://registry.npmjs.org \ - "oxlint@${OXLINT_VERSION}" "@oxlint/binding-linux-x64-gnu@${OXLINT_VERSION}" >/dev/null; then - echo "::warning::oxlint package download failed - manifest will record missing output." - oxlint_can_scan=false - elif [ ! -s "$oxlint_package" ] || [ ! -s "$oxlint_binding_package" ]; then - echo "::warning::oxlint package tarball missing after download - manifest will record missing output." - oxlint_can_scan=false - elif [ "$(tarball_size "$oxlint_package")" -le 1024 ] \ - || [ "$(tarball_size "$oxlint_binding_package")" -le 1024 ]; then - echo "::warning::oxlint package tarball at or below 1024 bytes after download - manifest will record missing output." - oxlint_can_scan=false - elif [ "$(sri_sha512 "$oxlint_package")" != "$OXLINT_NPM_INTEGRITY" ]; then - echo "::warning::oxlint package integrity mismatch - manifest will record missing output." - oxlint_can_scan=false - elif [ "$(sri_sha512 "$oxlint_binding_package")" != "$OXLINT_LINUX_X64_GNU_INTEGRITY" ]; then - echo "::warning::oxlint linux binding integrity mismatch - manifest will record missing output." - oxlint_can_scan=false - elif ! npm install --silent --prefix "$oxlint_install_dir" --ignore-scripts --omit=optional \ - --registry=https://registry.npmjs.org --no-audit --no-fund \ - "$oxlint_package" "$oxlint_binding_package"; then - echo "::warning::oxlint verified package install failed - manifest will record missing output." - oxlint_can_scan=false - elif [ ! -x "$oxlint_bin" ]; then - echo "::warning::oxlint binary missing after verified install - manifest will record missing output." - oxlint_can_scan=false - elif ! oxlint_version="$("$oxlint_bin" --version 2>/dev/null)"; then - oxlint_version="" - oxlint_can_scan=false - else - oxlint_detected_version="$(printf '%s\n' "$oxlint_version" | sed -nE 's/.*([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' | head -n 1)" - if [ "$oxlint_detected_version" != "$OXLINT_VERSION" ]; then - oxlint_can_scan=false - fi - fi - if [ "$oxlint_can_scan" = false ]; then - echo "::warning::oxlint version mismatch or unavailable: expected ${OXLINT_VERSION}, got '${oxlint_version:-unavailable}' - manifest will record missing output." - fi - if [ "$oxlint_can_scan" = true ]; then - if ! "$oxlint_bin" \ - --config "$oxlint_config" \ - --disable-nested-config \ - --no-ignore \ - -A all -D correctness \ - --format sarif \ - --no-error-on-unmatched-pattern \ - -- \ - "${files[@]}" > "$raw"; then - if [ ! -s "$raw" ]; then - echo "::warning::oxlint scan failed and produced no SARIF output - manifest will record missing output." - fi - fi - fi - fi - if [ -s "$raw" ]; then - python3 "$RUNNER_DIR/.github/scripts/sigilix_sarif_contract.py" \ - oxlint "$raw" "$out" --cap "$RESULT_CAP" --ensure-run \ - || echo "::warning::oxlint SARIF normalization failed - manifest will record missing output." - fi - fi + bash "$RUNNER_DIR/.github/scripts/run_oxlint.sh" - name: Run ast-grep to SARIF if: ${{ inputs.ast-grep }} @@ -882,6 +871,16 @@ jobs: FLAKE8_ENABLED: ${{ inputs.flake8 }} KNIP_ENABLED: ${{ inputs.knip }} GOLANGCI_LINT_ENABLED: ${{ inputs.golangci-lint }} + BUF_ENABLED: ${{ inputs.buf }} + SQLFLUFF_ENABLED: ${{ inputs.sqlfluff }} + PRISMA_LINT_ENABLED: ${{ inputs.prisma-lint }} + RUBOCOP_ENABLED: ${{ inputs.rubocop }} + PHPSTAN_ENABLED: ${{ inputs.phpstan }} + PHPMD_ENABLED: ${{ inputs.phpmd }} + PHPCS_ENABLED: ${{ inputs.phpcs }} + CLIPPY_ENABLED: ${{ inputs.clippy }} + DETEKT_ENABLED: ${{ inputs.detekt }} + SWIFTLINT_ENABLED: ${{ inputs.swiftlint }} ACTIONLINT_ENABLED: ${{ inputs.actionlint }} SHELLCHECK_ENABLED: ${{ inputs.shellcheck }} GITLEAKS_ENABLED: ${{ inputs.gitleaks }} diff --git a/README.md b/README.md index 01e7bf6..5c20575 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,16 @@ Current staged catalog: | Flake8 | on | Converts Flake8 text output to SARIF when a `.flake8` marker is present. The config content is ignored; Sigilix runs high-confidence PyFlakes/parse checks only to avoid duplicating broad Ruff/Pylint style feedback. | | Knip | on | Converts Knip JSON output to SARIF. Uses a Sigilix-owned JavaScript/TypeScript profile for unresolved imports, unlisted dependencies, and missing package-script binaries; broad unused-export/file reports are not enabled. | | golangci-lint | on | Native SARIF with Sigilix metadata for Go repositories. Uses a runner-controlled standard linter profile and skips caller golangci config/plugins. | +| Buf | on | Converts Buf JSON-lines lint output to SARIF for Protobuf files. Uses a pinned Linux x86_64 Buf binary, verifies its SHA256 before execution, and runs with a runner-owned v2 MINIMAL config. Updating Buf requires changing both `BUF_VERSION` and `BUF_LINUX_X86_64_SHA256`. | +| SQLFluff | on | Converts SQLFluff JSON output to SARIF for SQL files using a runner-owned raw-templater config; CodeRabbit's documented low-signal ignored codes are excluded. | +| Prisma Lint | on | Converts Prisma Lint JSON output to SARIF for `.prisma` files when a non-JS Prisma Lint config is present. The pinned npm package integrity is verified before install. | +| RuboCop | on | Converts RuboCop JSON output to SARIF for Ruby files using a runner-owned config so caller plugin/gem loading is not executed. | +| PHPStan | on | Converts PHPStan JSON output to SARIF for PHP projects using a runner-owned config with no bootstrap/include directives. | +| PHPMD | on | Converts PHPMD JSON output to SARIF for PHP files using the low-noise `unusedcode` ruleset. | +| PHPCS | on | Converts PHPCS JSON output to SARIF when `phpcs.xml` or `phpcs.xml.dist` is present, using a runner-owned PSR-12 standard to avoid custom sniff execution. | +| Clippy | on | Converts Cargo/Clippy JSON compiler messages to SARIF for Rust projects with `Cargo.toml`, without enabling default Cargo features. | +| detekt | on | Native SARIF with Sigilix metadata for Kotlin files using a pinned detekt CLI jar and runner-owned config. | +| SwiftLint | on | Converts SwiftLint JSON output to SARIF for Swift files, with CodeRabbit's documented low-signal style rules disabled. | | actionlint | on | Converts actionlint JSON to SARIF for GitHub Actions workflows. | | ShellCheck | on | Converts ShellCheck `json1` output to SARIF. | | YAMLlint | on | Converts YAMLlint parsable output to SARIF with relaxed defaults for config feedback. | @@ -62,6 +72,10 @@ Current staged catalog: > `regal` now defaults to `true`. Set `regal: false` (boolean) in the caller workflow to suppress it. > `opengrep` and `brakeman` now default to `true`. Set the matching boolean input to `false` > to suppress one of them; `opengrep-config` accepts comma-separated OpenGrep rulesets. +> `buf` now defaults to `true`. Set `buf: false` (boolean) in the caller workflow to suppress it. +> `sqlfluff`, `prisma-lint`, `rubocop`, `phpstan`, `phpmd`, `phpcs`, `clippy`, `detekt`, +> and `swiftlint` now default to `true`. Set the matching boolean input to `false` to suppress +> one of them. These SIG-107 slices move the runner toward broader third-party tool parity. The Sigilix metadata contract is currently attached to every listed tool. @@ -102,7 +116,8 @@ a moving ref cannot prove which version of the runner ran. Default-on tool booleans: `semgrep`, `eslint`, `ruff`, `actionlint`, `shellcheck`, `yamllint`, `markdownlint`, `dotenv-linter`, `checkmake`, `gitleaks`, `osv-scanner`, `zizmor`, `hadolint`, `biome`, `oxlint`, `ast-grep`, `pylint`, `flake8`, `knip`, `golangci-lint`, `htmlhint`, -`stylelint`, `tflint`, `regal`, `opengrep`, `brakeman`, and `tsc`. +`stylelint`, `tflint`, `regal`, `opengrep`, `brakeman`, `buf`, `sqlfluff`, `prisma-lint`, +`rubocop`, `phpstan`, `phpmd`, `phpcs`, `clippy`, `detekt`, `swiftlint`, and `tsc`. Default-off opt-in tool booleans: `checkov`, `trivy`, and `trufflehog`. @@ -126,9 +141,10 @@ Other useful inputs: bound to your `repository`, commit `sha`, and the called workflow ref. Sigilix verifies it (RS256 against GitHub's JWKS) plus a provenance gate before trusting any finding, and enforces single-use so a receipt can't be replayed. -- **Best-effort caller CI.** Tool findings and tool download failures do not fail the caller's CI; - they degrade to SARIF metadata, empty SARIF runs, or warnings. Malformed workflow/script changes - in this runner still fail this repo's own CI. +- **Best-effort caller CI.** Tool findings and most tool availability failures do not fail the + caller's CI; they degrade to SARIF metadata, empty SARIF runs, or warnings. Integrity or contract + failures for a verified binary can fail the scan job so Sigilix does not trust unverified tool + output. Malformed workflow/script changes in this runner still fail this repo's own CI. - **Coverage manifest.** Every run uploads and posts `scan-manifest.json` next to SARIF, recording each configured tool as `produced`, `empty`, `missing-output`, `invalid-output`, or `disabled`. Sigilix stores it with the OIDC receipt so green CI never has to mean "all tools ran."