Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/config/tool-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
"env": "ESLINT_ENABLED",
"output": "eslint.sarif"
},
{
"id": "tsc",
"env": "TSC_ENABLED",
"output": "tsc.sarif"
},
{
"id": "ruff",
"env": "RUFF_ENABLED",
Expand Down
123 changes: 123 additions & 0 deletions .github/scripts/run_tsc.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail

: "${RESULT_CAP:?}"
: "${RUNNER_DIR:?}"
: "${RUNNER_TEMP:?}"
: "${SARIF_DIR:?}"
: "${SOURCE_DIR:?}"
: "${TYPESCRIPT_NPM_INTEGRITY:?}"
: "${TYPESCRIPT_VERSION:?}"

SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd -P)"
RUNNER_DIR="$(cd "$RUNNER_DIR" && pwd -P)"

raw="$SARIF_DIR/tsc.txt"
out="$SARIF_DIR/tsc.sarif"
tsc_install_dir=""
tsc_bin=""
tsc_package=""
tsc_version=""
configs_list=""

mkdir -p "$SARIF_DIR" "$RUNNER_TEMP"
configs_list="$(mktemp "$RUNNER_TEMP/tsc-configs.XXXXXX")"

cleanup_tsc() {
rm -f "$configs_list"
if [ -n "$tsc_install_dir" ]; then rm -rf "$tsc_install_dir"; fi
}
trap cleanup_tsc EXIT

emit_empty_raw() {
: > "$raw"
}

verify_package_integrity() {
local actual
actual="sha512-$(openssl dgst -sha512 -binary "$1" | openssl base64 -A)"
[ "$actual" = "$TYPESCRIPT_NPM_INTEGRITY" ]
}

discover_tsconfigs() {
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 '.venv' \
-o -name 'vendor' -o -name '__pycache__' -o -name '.tox' -o -name '.mypy_cache' \
-o -name '.pytest_cache' -o -name '.terraform' \) -prune \) -o \
\( -type f \( -name 'tsconfig.json' -o -name 'tsconfig.*.json' \) -print0 \)
}

cd "$SOURCE_DIR"
if ! discover_tsconfigs > "$configs_list"; then
echo "::warning::TypeScript config discovery failed - emitting empty TypeScript SARIF run."
emit_empty_raw
elif [[ ! "$TYPESCRIPT_VERSION" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]]; then
echo "::warning::TypeScript version must be a pinned x.y.z version - emitting empty TypeScript SARIF run."
emit_empty_raw
elif [[ ! "$TYPESCRIPT_NPM_INTEGRITY" =~ ^sha512-[A-Za-z0-9+/]+={0,2}$ ]]; then
echo "::warning::TypeScript package integrity must be a pinned sha512 value - emitting empty TypeScript SARIF run."
emit_empty_raw
else
configs=()
while IFS= read -r -d '' config; do
configs+=("$config")
done < "$configs_list"

if [ "${#configs[@]}" -eq 0 ]; then
emit_empty_raw
else
tsc_can_scan=true
tsc_install_dir="$(mktemp -d "$RUNNER_TEMP/tsc-${TYPESCRIPT_VERSION}.XXXXXX")"
tsc_bin="$tsc_install_dir/node_modules/.bin/tsc"
if ! tsc_package="$(npm pack --silent --pack-destination "$tsc_install_dir" \
--registry=https://registry.npmjs.org \
"typescript@${TYPESCRIPT_VERSION}" | tail -n 1)"; then
echo "::warning::TypeScript package download failed - emitting empty TypeScript SARIF run."
tsc_can_scan=false
else
tsc_package="$tsc_install_dir/${tsc_package##*/}"
fi
Comment thread
damartinezjulio marked this conversation as resolved.

if [ "$tsc_can_scan" = true ] && [ ! -f "$tsc_package" ]; then
echo "::warning::TypeScript package tarball missing after download - emitting empty TypeScript SARIF run."
tsc_can_scan=false
elif [ "$tsc_can_scan" = true ] && ! verify_package_integrity "$tsc_package"; then
echo "::warning::TypeScript package integrity mismatch - emitting empty TypeScript SARIF run."
tsc_can_scan=false
elif [ "$tsc_can_scan" = true ] && ! npm install --silent --prefix "$tsc_install_dir" --ignore-scripts --omit=optional \
--registry=https://registry.npmjs.org --no-audit --no-fund \
"$tsc_package" >/dev/null; then
echo "::warning::TypeScript package install failed - emitting empty TypeScript SARIF run."
tsc_can_scan=false
elif [ "$tsc_can_scan" = true ] && [ ! -x "$tsc_bin" ]; then
echo "::warning::TypeScript compiler binary missing after install - emitting empty TypeScript SARIF run."
tsc_can_scan=false
Comment thread
damartinezjulio marked this conversation as resolved.
elif [ "$tsc_can_scan" = true ] && ! tsc_version="$("$tsc_bin" --version 2>/dev/null)"; then
tsc_can_scan=false
elif [ "$tsc_can_scan" = true ]; then
Comment thread
damartinezjulio marked this conversation as resolved.
tsc_version="$(printf '%s\n' "$tsc_version" | grep -Eo '^Version[[:space:]]+[0-9]+[.][0-9]+[.][0-9]+' | grep -Eo '[0-9]+[.][0-9]+[.][0-9]+' | head -n 1 || true)"
if [ "$tsc_version" != "$TYPESCRIPT_VERSION" ]; then
Comment thread
damartinezjulio marked this conversation as resolved.
tsc_can_scan=false
fi
fi

if [ "$tsc_can_scan" = false ]; then
Comment thread
damartinezjulio marked this conversation as resolved.
echo "::warning::TypeScript version mismatch or unavailable: expected ${TYPESCRIPT_VERSION}, got '${tsc_version:-unavailable}' - emitting empty TypeScript SARIF run."
emit_empty_raw
else
: > "$raw"
for config in "${configs[@]}"; do
tsc_exit=0
"$tsc_bin" --project "$config" --noEmit --pretty false --skipLibCheck --noErrorTruncation >> "$raw" 2>&1 || tsc_exit=$?
if [ "$tsc_exit" -gt 2 ]; then
echo "::warning::TypeScript exited with code $tsc_exit for $config - results may be incomplete."
fi
printf '\n' >> "$raw"
done
fi
fi
fi

python3 "$RUNNER_DIR/.github/scripts/tsc_to_sarif.py" "$raw" "$out" \
--base-dir "$SOURCE_DIR" --cap "$RESULT_CAP"
2 changes: 2 additions & 0 deletions .github/scripts/sigilix_sarif_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
{
"semgrep",
"eslint",
"tsc",
"ruff",
"pylint",
"knip",
Expand All @@ -34,6 +35,7 @@
DEFAULT_TOOL_NAMES = {
"semgrep": "Semgrep",
"eslint": "ESLint",
"tsc": "TypeScript Compiler",
"ruff": "Ruff",
"pylint": "Pylint",
"knip": "Knip",
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/sigilix_sarif_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ def test_contract_cli_attaches_metadata_for_new_native_language_tools(self):
ALL_TOOL_OUTPUTS = {
"semgrep": "semgrep.sarif",
"eslint": "eslint.sarif",
"tsc": "tsc.sarif",
"ruff": "ruff.sarif",
"pylint": "pylint.sarif",
"knip": "knip.sarif",
Expand All @@ -503,7 +504,6 @@ def test_contract_cli_attaches_metadata_for_new_native_language_tools(self):
**CONTAINER_TOOL_OUTPUTS,
}


class SigilixWorkflowContractTest(unittest.TestCase):
def workflow_text(self):
path = os.path.join(os.path.dirname(__file__), "..", "workflows", "scan.yml")
Expand Down
137 changes: 137 additions & 0 deletions .github/scripts/tsc_to_sarif.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import argparse
import os
import re
import sys

from sarif_converter_common import make_document, make_result, write_json_file


TSC_TOOL_ID = "tsc"
TSC_TOOL_NAME = "TypeScript Compiler"
TSC_INFORMATION_URI = "https://www.typescriptlang.org/docs/handbook/compiler-options.html"
MAX_MESSAGE_LENGTH = 4096
DEPENDENCY_NOISE_CODES = frozenset({"TS2307", "TS2688", "TS7016"})

_DIAGNOSTIC_RE = re.compile(
r"^(?P<path>.+)\((?P<line>[0-9]+),(?P<column>[0-9]+)\): "
r"(?P<severity>error|warning) (?P<code>TS[0-9]+): (?P<message>.*)$"
)
_QUOTED_SPECIFIER_RE = re.compile(r"'([^']+)'")


def convert_tsc_text(text, base_dir=".", cap=None):
results = []
for diagnostic in _diagnostics(text):
code = diagnostic["code"]
if _is_dependency_noise(diagnostic):
continue
if not _is_inside_base_dir(diagnostic["path"], base_dir):
continue
results.append(
make_result(
f"tsc/{code}",
_level_for_severity(diagnostic["severity"]),
_bounded_text(diagnostic["message"], MAX_MESSAGE_LENGTH),
diagnostic["path"],
line=diagnostic["line"],
column=diagnostic["column"],
base_dir=base_dir,
)
)
return make_document(TSC_TOOL_NAME, TSC_TOOL_ID, results, information_uri=TSC_INFORMATION_URI, cap=cap)


def _diagnostics(text):
for line in str(text or "").splitlines():
match = _DIAGNOSTIC_RE.match(line)
if not match:
continue
groups = match.groupdict()
yield {
"path": groups["path"],
"line": _positive_int(groups["line"]),
"column": _positive_int(groups["column"]),
"severity": groups["severity"],
"code": groups["code"],
"message": groups["message"],
}


def _level_for_severity(severity):
return "error" if severity == "error" else "warning"


def _is_dependency_noise(diagnostic):
code = diagnostic["code"]
if code not in DEPENDENCY_NOISE_CODES:
return False
specifier = _quoted_specifier(diagnostic["message"])
return _looks_like_external_package(specifier)


def _quoted_specifier(message):
match = _QUOTED_SPECIFIER_RE.search(str(message or ""))
return match.group(1) if match else ""


def _looks_like_external_package(specifier):
if not specifier:
return True
if specifier.startswith((".", "/", "#", "@/")):
return False
if specifier.startswith("~") and (len(specifier) == 1 or specifier[1] == "/"):
Comment thread
damartinezjulio marked this conversation as resolved.
Comment thread
damartinezjulio marked this conversation as resolved.
Comment thread
damartinezjulio marked this conversation as resolved.
return False
if specifier.startswith("@") and len(specifier) > 1 and specifier[1] not in ("/", "~", "."):
parts = specifier.split("/")
if parts[0] == "@types":
return True
return specifier.count("/") == 1
return "/" not in specifier


def _is_inside_base_dir(path, base_dir):
base = os.path.realpath(base_dir or ".")
candidate = path if os.path.isabs(str(path or "")) else os.path.join(base, str(path or ""))
candidate = os.path.realpath(candidate)
Comment thread
damartinezjulio marked this conversation as resolved.
try:
return os.path.commonpath([base, candidate]) == base
except ValueError:
return False


def _positive_int(value):
try:
integer = int(value)
except (TypeError, ValueError):
return None
return integer if integer > 0 else None


def _bounded_text(value, limit):
text = str(value or "").strip()
if len(text) <= limit:
return text
return text[: limit - 3] + "..."


def _main(argv):
parser = argparse.ArgumentParser(description="Convert TypeScript compiler 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, "r", encoding="utf-8", errors="replace") as handle:
text = handle.read()
except OSError as exc:
print(f"::warning::Failed to read TypeScript output: {exc}", file=sys.stderr)
text = ""
document = convert_tsc_text(text, base_dir=args.base_dir, cap=args.cap)
write_json_file(args.output, document)
return 0


if __name__ == "__main__":
sys.exit(_main(sys.argv[1:]))
Loading
Loading