src/pqc_lint/cli.py
| 1 | """Command-line interface for pqc-lint.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import sys |
| 6 | |
| 7 | import click |
| 8 | |
| 9 | from pqc_lint import __version__ |
| 10 | from pqc_lint.findings import Severity |
| 11 | from pqc_lint.reporters import REPORTERS |
| 12 | from pqc_lint.rules import RULES |
| 13 | from pqc_lint.scanner import DEFAULT_EXCLUDES, Scanner |
| 14 | |
| 15 | |
| 16 | @click.group() |
| 17 | @click.version_option(version=__version__, prog_name="pqc-lint") |
| 18 | def main() -> None: |
| 19 | """PQC Lint - find classical cryptography and suggest PQC replacements.""" |
| 20 | |
| 21 | |
| 22 | @main.command() |
| 23 | @click.argument("path", type=click.Path(exists=True), default=".") |
| 24 | @click.option( |
| 25 | "--format", "output_format", |
| 26 | type=click.Choice(["text", "json", "sarif", "github"]), |
| 27 | default="text", |
| 28 | help="Output format.", |
| 29 | ) |
| 30 | @click.option( |
| 31 | "--output", "-o", "output_file", |
| 32 | type=click.Path(), |
| 33 | default=None, |
| 34 | help="Write output to file instead of stdout.", |
| 35 | ) |
| 36 | @click.option( |
| 37 | "--fail-on", |
| 38 | type=click.Choice(["critical", "high", "medium", "low", "info"]), |
| 39 | default="high", |
| 40 | help="Exit with non-zero status if any finding is >= this severity.", |
| 41 | ) |
| 42 | @click.option( |
| 43 | "--languages", "-l", |
| 44 | default="", |
| 45 | help="Comma-separated languages to scan (python,javascript,go,rust,java,c). Empty=all.", |
| 46 | ) |
| 47 | @click.option( |
| 48 | "--exclude", |
| 49 | default="", |
| 50 | help="Comma-separated glob patterns to exclude (in addition to defaults).", |
| 51 | ) |
| 52 | def scan( |
| 53 | path: str, |
| 54 | output_format: str, |
| 55 | output_file: str | None, |
| 56 | fail_on: str, |
| 57 | languages: str, |
| 58 | exclude: str, |
| 59 | ) -> None: |
| 60 | """Scan PATH for classical cryptography usage.""" |
| 61 | lang_tuple = tuple( |
| 62 | s.strip().lower() for s in languages.split(",") if s.strip() |
| 63 | ) |
| 64 | extra_excludes = tuple(s.strip() for s in exclude.split(",") if s.strip()) |
| 65 | excludes = DEFAULT_EXCLUDES + extra_excludes |
| 66 | |
| 67 | scanner = Scanner(languages=lang_tuple, excludes=excludes) |
| 68 | report = scanner.scan_path(path) |
| 69 | |
| 70 | reporter_cls = REPORTERS[output_format] |
| 71 | output = reporter_cls().render(report) |
| 72 | |
| 73 | if output_file: |
| 74 | with open(output_file, "w", encoding="utf-8") as f: |
| 75 | f.write(output) |
| 76 | click.echo(f"Wrote {output_format} report to {output_file}") |
| 77 | else: |
| 78 | click.echo(output, nl=False) |
| 79 | |
| 80 | # exit code based on fail-on |
| 81 | threshold = Severity.from_str(fail_on) |
| 82 | if report.has_failing(threshold): |
| 83 | sys.exit(1) |
| 84 | |
| 85 | |
| 86 | @main.command() |
| 87 | def rules() -> None: |
| 88 | """List all rules.""" |
| 89 | click.echo(f"pqc-lint rules ({len(RULES)})\n") |
| 90 | for r in RULES: |
| 91 | click.echo(f" {r.id} [{r.severity.value.upper():8}] {r.title}") |
| 92 | click.echo(f" primitive: {r.classical_primitive}") |
| 93 | click.echo(f" languages: {', '.join(r.languages)}") |
| 94 | if r.cwe: |
| 95 | click.echo(f" cwe: {r.cwe}") |
| 96 | click.echo() |
| 97 | |
| 98 | |
| 99 | if __name__ == "__main__": |
| 100 | main() |
| 101 | |