aift_cli

AIFT CLI — Command-line interface for automated forensic triage.

Provides a standalone command-line tool that runs the full AIFT forensic analysis pipeline without starting the Flask web server. Evidence files or folders are processed, artifacts are parsed, AI analysis is performed, and both HTML and JSON reports are generated.

Usage:

python aift_cli.py -e /path/to/evidence -p "Investigate suspicious activity" python aift_cli.py -e /evidence/folder -p @prompt.txt -o /output --profile recommended

Attributes:
  • EXIT_SUCCESS: Exit code for successful completion (0).
  • EXIT_FAILURE: Exit code for fatal errors (1).
  • EXIT_PARTIAL: Exit code for partial success with warnings (2).
  1#!/usr/bin/env python3
  2"""AIFT CLI — Command-line interface for automated forensic triage.
  3
  4Provides a standalone command-line tool that runs the full AIFT forensic
  5analysis pipeline without starting the Flask web server. Evidence files
  6or folders are processed, artifacts are parsed, AI analysis is performed,
  7and both HTML and JSON reports are generated.
  8
  9Usage:
 10    python aift_cli.py -e /path/to/evidence -p "Investigate suspicious activity"
 11    python aift_cli.py -e /evidence/folder -p @prompt.txt -o /output --profile recommended
 12
 13Attributes:
 14    EXIT_SUCCESS: Exit code for successful completion (0).
 15    EXIT_FAILURE: Exit code for fatal errors (1).
 16    EXIT_PARTIAL: Exit code for partial success with warnings (2).
 17"""
 18
 19from __future__ import annotations
 20
 21import argparse
 22import logging
 23import sys
 24from pathlib import Path
 25from typing import Any
 26
 27from runtime_compat import UnsupportedPythonVersionError, assert_supported_python_version
 28
 29EXIT_SUCCESS = 0
 30EXIT_FAILURE = 1
 31EXIT_PARTIAL = 2
 32
 33AIFT_LOGO = """       d8888 8888888 8888888888 88888888888
 34      d88888   888   888            888
 35     d88P888   888   888            888
 36    d88P 888   888   8888888        888
 37   d88P  888   888   888            888
 38  d88P   888   888   888            888
 39 d8888888888   888   888            888
 40d88P     888 8888888 888            888
 41
 42╭─╮╷   ╭─╴╭─╮╭─╮╭─╴╭╮╷╭─╮╷╭─╴   ╶┬╴╭─╮╷╭─╮╭─╴╭─╴
 43├─┤│   ├╴ │ │├┬╯├╴ │╰┤╰─╮││      │ ├┬╯│├─┤│╶╮├╴
 44╵ ╵╵   ╵  ╰─╯╵╰╴╰─╴╵ ╵╰─╯╵╰─╴    ╵ ╵╰╴╵╵ ╵╰─╯╰─╴"""
 45
 46
 47def _build_parser() -> argparse.ArgumentParser:
 48    """Build and return the argument parser for the AIFT CLI.
 49
 50    Returns:
 51        Configured ``argparse.ArgumentParser`` instance.
 52    """
 53    parser = argparse.ArgumentParser(
 54        prog="aift_cli.py",
 55        description="AIFT - AI Forensic Triage (CLI Mode)",
 56        formatter_class=argparse.RawDescriptionHelpFormatter,
 57    )
 58
 59    required = parser.add_argument_group("required arguments")
 60    required.add_argument(
 61        "-e", "--evidence",
 62        required=True,
 63        metavar="EVIDENCE",
 64        help=(
 65            "Path to evidence file or folder. If a folder is given, all "
 66            "supported evidence files within it will be discovered and processed."
 67        ),
 68    )
 69    required.add_argument(
 70        "-p", "--prompt",
 71        required=True,
 72        metavar="PROMPT",
 73        help=(
 74            "Investigation context / prompt for AI analysis. Can be a string "
 75            "or a path to a text file (prefix with @, e.g., @prompt.txt)."
 76        ),
 77    )
 78
 79    optional = parser.add_argument_group("optional arguments")
 80    optional.add_argument(
 81        "-o", "--output",
 82        metavar="OUTPUT",
 83        default=None,
 84        help=(
 85            "Output directory for reports. If omitted, reports are written to "
 86            "the created case folder under cases/<case_id>/reports."
 87        ),
 88    )
 89    optional.add_argument(
 90        "--profile",
 91        metavar="PROFILE",
 92        default="recommended",
 93        help=(
 94            'Artifact profile name or JSON file path. Defaults to "recommended". '
 95            "Use --list-profiles to see available profiles."
 96        ),
 97    )
 98    optional.add_argument(
 99        "-c", "--config",
100        metavar="CONFIG",
101        default=None,
102        help=(
103            "Path to a YAML config file. Defaults to config/config.yaml "
104            "in the AIFT installation directory."
105        ),
106    )
107    optional.add_argument(
108        "--case-name",
109        metavar="NAME",
110        default=None,
111        help="Human-readable case name. Auto-generated if not provided.",
112    )
113    optional.add_argument(
114        "--skip-hashing",
115        action=argparse.BooleanOptionalAction,
116        default=None,
117        help=(
118            "Skip SHA-256/MD5 hash computation on evidence "
119            "(--no-skip-hashing forces hashing). When neither flag is given, "
120            "the config's evidence.compute_hashes setting decides; hashing "
121            "stays enabled unless that key is set to false."
122        ),
123    )
124    optional.add_argument(
125        "--date-start",
126        metavar="DATE",
127        default=None,
128        help="Start date for analysis filtering (YYYY-MM-DD).",
129    )
130    optional.add_argument(
131        "--date-end",
132        metavar="DATE",
133        default=None,
134        help="End date for analysis filtering (YYYY-MM-DD).",
135    )
136    optional.add_argument(
137        "--quiet",
138        action="store_true",
139        default=False,
140        help="Suppress progress output. Only print final result.",
141    )
142    optional.add_argument(
143        "--no-logo",
144        action="store_true",
145        default=False,
146        help="Skip the startup logo and print only the AIFT version line.",
147    )
148    optional.add_argument(
149        "--verbose",
150        action="store_true",
151        default=False,
152        help="Enable debug-level logging.",
153    )
154    optional.add_argument(
155        "--list-profiles",
156        action="store_true",
157        default=False,
158        help="List available artifact profiles and exit.",
159    )
160    optional.add_argument(
161        "--version",
162        action="store_true",
163        default=False,
164        help="Show version and exit.",
165    )
166
167    return parser
168
169
170def _resolve_prompt(raw_prompt: str) -> str:
171    """Resolve the prompt argument, loading from file if prefixed with ``@``.
172
173    Args:
174        raw_prompt: The raw ``--prompt`` value from the CLI.
175
176    Returns:
177        The investigation prompt string.
178
179    Raises:
180        SystemExit: If the prompt file does not exist or cannot be read.
181    """
182    if raw_prompt.startswith("@"):
183        file_path = Path(raw_prompt[1:]).expanduser().resolve()
184        if not file_path.is_file():
185            print(f"ERROR: Prompt file not found: {file_path}", file=sys.stderr)
186            raise SystemExit(EXIT_FAILURE)
187        try:
188            return file_path.read_text(encoding="utf-8").strip()
189        except Exception as exc:
190            print(f"ERROR: Failed to read prompt file: {exc}", file=sys.stderr)
191            raise SystemExit(EXIT_FAILURE) from None
192    return raw_prompt
193
194
195def _format_duration(seconds: float) -> str:
196    """Format a duration in seconds as a human-readable string.
197
198    Args:
199        seconds: Duration in seconds.
200
201    Returns:
202        Formatted string like ``"2m 05s"`` or ``"45s"``.
203    """
204    minutes = int(seconds) // 60
205    secs = int(seconds) % 60
206    if minutes > 0:
207        return f"{minutes}m {secs:02d}s"
208    return f"{secs}s"
209
210
211def _format_startup_line() -> str:
212    """Return the one-line CLI startup label with centralized version metadata."""
213    from app.utils.version import TOOL_VERSION
214
215    return f"AIFT {TOOL_VERSION} - By Flip Forensics"
216
217
218def _prepare_stdout_for_text(text: str) -> None:
219    """Allow Unicode banner text to be printed on legacy Windows code pages."""
220    encoding = getattr(sys.stdout, "encoding", None) or "utf-8"
221    try:
222        text.encode(encoding)
223    except UnicodeEncodeError:
224        reconfigure = getattr(sys.stdout, "reconfigure", None)
225        if callable(reconfigure):
226            reconfigure(encoding="utf-8")
227
228
229def _print_startup_banner(include_logo: bool = True) -> None:
230    """Print the CLI startup banner.
231
232    Args:
233        include_logo: If True, print the AIFT ASCII logo before the version line.
234            If False, print only the version line.
235    """
236    if include_logo:
237        _prepare_stdout_for_text(AIFT_LOGO)
238        print(AIFT_LOGO)
239        print()
240    print(_format_startup_line())
241
242
243def _make_progress_callback(quiet: bool) -> Any:
244    """Create a progress callback function for the automation engine.
245
246    Args:
247        quiet: If True, return None (suppress progress output).
248
249    Returns:
250        A callback ``(phase, message, percentage) -> None`` or None.
251    """
252    if quiet:
253        return None
254
255    phase_labels = {
256        "discovery": "DISCOVERY",
257        "hashing": "HASHING  ",
258        "parsing": "PARSING  ",
259        "analysis": "ANALYSIS ",
260        "reporting": "REPORTING",
261    }
262
263    def _callback(phase: str, message: str, percentage: float) -> None:
264        """Print a formatted progress line to stdout.
265
266        Args:
267            phase: Pipeline phase name.
268            message: Human-readable status message.
269            percentage: Progress within the phase (0.0--100.0).
270        """
271        label = phase_labels.get(phase, phase.upper().ljust(9))
272        print(f"[{label}] {message}")
273
274    return _callback
275
276
277def _resolve_date_range(
278    date_start: str | None,
279    date_end: str | None,
280) -> tuple[str, str] | None:
281    """Validate CLI date-range arguments and return an engine tuple.
282
283    Args:
284        date_start: Raw ``--date-start`` value.
285        date_end: Raw ``--date-end`` value.
286
287    Returns:
288        ``(start_date, end_date)`` tuple, or ``None`` if no range was supplied.
289
290    Raises:
291        SystemExit: If only one side is provided, or the shared validator
292            rejects the format/range.
293    """
294    if not date_start and not date_end:
295        return None
296    if not date_start or not date_end:
297        print(
298            "ERROR: Both --date-start and --date-end must be provided together.",
299            file=sys.stderr,
300        )
301        raise SystemExit(EXIT_FAILURE)
302
303    from app.utils.artifact_profiles import validate_analysis_date_range
304
305    try:
306        validated = validate_analysis_date_range(
307            {"start_date": date_start, "end_date": date_end}
308        )
309    except ValueError as exc:
310        print(f"ERROR: Invalid date range: {exc}", file=sys.stderr)
311        raise SystemExit(EXIT_FAILURE) from None
312
313    if validated is None:
314        return None
315    return (validated["start_date"], validated["end_date"])
316
317
318def _print_summary(result: Any) -> None:
319    """Print the final summary block after automation completes.
320
321    The evidence line reports how many of the discovered evidence images
322    were processed successfully; images that failed to process are listed
323    in the warnings section.
324
325    Args:
326        result: An ``AutomationResult`` instance from the automation engine.
327    """
328    separator = "=" * 60
329    print()
330    print(separator)
331    if result.success:
332        print("AIFT Automation Complete")
333    else:
334        print("AIFT Automation Complete (with errors)")
335    print(separator)
336    successful_images = getattr(result, "successful_images", 0)
337    print(f"  Case ID:      {result.case_id or 'N/A'}")
338    print(
339        f"  Evidence:     {successful_images} of "
340        f"{len(result.evidence_files)} discovered evidence image(s) "
341        "processed successfully"
342    )
343    print(f"  Duration:     {_format_duration(result.duration_seconds)}")
344    print()
345
346    if result.html_report_path or result.json_report_path:
347        print("  Reports:")
348        if result.html_report_path:
349            print(f"    HTML: {result.html_report_path}")
350        if result.json_report_path:
351            print(f"    JSON: {result.json_report_path}")
352        case_local_html = getattr(result, "case_local_html_report_path", None)
353        if case_local_html and case_local_html != result.html_report_path:
354            print(f"    Case-local HTML: {case_local_html}")
355        case_local_json = getattr(result, "case_local_json_report_path", None)
356        if case_local_json and case_local_json != result.json_report_path:
357            print(f"    Case-local JSON: {case_local_json}")
358        print()
359
360    if getattr(result, "analysis_results_path", None):
361        print("  Case Analysis Payload:")
362        print(f"    analysis_results.json: {result.analysis_results_path}")
363        print()
364
365    if result.errors:
366        print(f"  Errors: {len(result.errors)}")
367        for err in result.errors:
368            print(f"    - {err}")
369        print()
370
371    if result.warnings:
372        print(f"  Warnings: {len(result.warnings)}")
373        for warn in result.warnings:
374            print(f"    - {warn}")
375        print()
376
377    notices = getattr(result, "notices", None) or []
378    if notices:
379        print(f"  Notes: {len(notices)}")
380        for note in notices:
381            print(f"    - {note}")
382        print()
383
384    print(separator)
385
386
387def _list_profiles() -> None:
388    """Load and print all available artifact profiles, then exit.
389
390    Profiles are always listed from the repository ``profile`` directory;
391    the ``--config`` option does not influence profile resolution.
392
393    Raises:
394        SystemExit: Always exits with code 0 after printing.
395    """
396    from app.utils.artifact_profiles import compose_profile_summaries, resolve_profiles_root
397
398    profiles_root = resolve_profiles_root()
399    profiles = compose_profile_summaries(profiles_root)
400
401    if not profiles:
402        print("No artifact profiles found.")
403        raise SystemExit(EXIT_SUCCESS)
404
405    print("Available artifact profiles:\n")
406    for profile in profiles:
407        name = profile.get("name", "unknown")
408        builtin = profile.get("builtin", False)
409        artifact_count = profile.get("artifact_count", 0)
410        tag = " (built-in)" if builtin else ""
411        print(f"  {name}{tag}{artifact_count} artifacts")
412        notice = str(profile.get("notice", "") or "").strip()
413        if notice:
414            print(f"      Note: {notice}")
415
416    print()
417    raise SystemExit(EXIT_SUCCESS)
418
419
420def _show_version() -> None:
421    """Print the AIFT version and exit.
422
423    Raises:
424        SystemExit: Always exits with code 0 after printing.
425    """
426    from app.utils.version import TOOL_VERSION
427
428    print(f"AIFT v{TOOL_VERSION}")
429    raise SystemExit(EXIT_SUCCESS)
430
431
432def _configure_logging(verbose: bool) -> None:
433    """Configure Python logging for the CLI session.
434
435    Args:
436        verbose: If True, set loggers to DEBUG. Otherwise, only errors are
437            printed.
438    """
439    level = logging.DEBUG if verbose else logging.ERROR
440    logging.basicConfig(
441        level=level,
442        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
443        stream=sys.stderr,
444    )
445    root_logger = logging.getLogger()
446    root_logger.setLevel(level)
447    for handler in root_logger.handlers:
448        handler.setLevel(level)
449    logging.getLogger("app").setLevel(level)
450
451
452def main() -> None:
453    """Parse arguments and run the AIFT automation pipeline.
454
455    This is the CLI entry point. It validates the Python version, parses
456    command-line arguments, resolves the investigation prompt, builds an
457    ``AutomationRequest``, calls ``run_automation()``, and prints the
458    summary.
459
460    Raises:
461        SystemExit: With exit code 0 (success), 1 (failure), or
462            2 (partial success with warnings).
463    """
464    assert_supported_python_version()
465
466    # Check for early-exit flags before full argument parsing, since
467    # --version and --list-profiles should work without -e and -p.
468    if "--version" in sys.argv[1:]:
469        _show_version()
470    if "--list-profiles" in sys.argv[1:]:
471        _configure_logging("--verbose" in sys.argv[1:])
472        _list_profiles()
473
474    parser = _build_parser()
475    args = parser.parse_args()
476
477    _configure_logging(args.verbose)
478    if not args.quiet:
479        _print_startup_banner(include_logo=not args.no_logo)
480
481    # Resolve prompt (may be a file reference).
482    prompt = _resolve_prompt(args.prompt)
483    if not prompt:
484        print("ERROR: Investigation prompt must not be empty.", file=sys.stderr)
485        raise SystemExit(EXIT_FAILURE)
486
487    date_range = _resolve_date_range(args.date_start, args.date_end)
488
489    # Resolve only explicit output directories.  When omitted, the automation
490    # engine resolves reports into the created case's reports directory.
491    output_dir = Path(args.output).resolve() if args.output else None
492
493    # Lazy-import the automation engine to avoid loading Flask.
494    from app.automation.engine import AutomationRequest, run_automation
495
496    request = AutomationRequest(
497        evidence_path=args.evidence,
498        prompt=prompt,
499        output_dir=output_dir,
500        profile_name=args.profile,
501        config_path=args.config,
502        case_name=args.case_name,
503        skip_hashing=args.skip_hashing,
504        date_range=date_range,
505    )
506
507    progress_callback = _make_progress_callback(args.quiet)
508
509    try:
510        result = run_automation(request, progress_callback=progress_callback)
511    except KeyboardInterrupt:
512        print("\nAborted by user.", file=sys.stderr)
513        raise SystemExit(EXIT_FAILURE) from None
514    except Exception as exc:
515        logging.getLogger(__name__).debug("Unhandled exception", exc_info=True)
516        print(f"ERROR: {exc}", file=sys.stderr)
517        if args.verbose:
518            import traceback
519            traceback.print_exc(file=sys.stderr)
520        raise SystemExit(EXIT_FAILURE) from None
521
522    _print_summary(result)
523
524    if not result.success:
525        raise SystemExit(EXIT_FAILURE)
526    if result.warnings:
527        raise SystemExit(EXIT_PARTIAL)
528    raise SystemExit(EXIT_SUCCESS)
529
530
531if __name__ == "__main__":
532    try:
533        main()
534    except UnsupportedPythonVersionError as error:
535        print(str(error), file=sys.stderr)
536        raise SystemExit(EXIT_FAILURE) from None
EXIT_SUCCESS = 0
EXIT_FAILURE = 1
EXIT_PARTIAL = 2
def main() -> None:
453def main() -> None:
454    """Parse arguments and run the AIFT automation pipeline.
455
456    This is the CLI entry point. It validates the Python version, parses
457    command-line arguments, resolves the investigation prompt, builds an
458    ``AutomationRequest``, calls ``run_automation()``, and prints the
459    summary.
460
461    Raises:
462        SystemExit: With exit code 0 (success), 1 (failure), or
463            2 (partial success with warnings).
464    """
465    assert_supported_python_version()
466
467    # Check for early-exit flags before full argument parsing, since
468    # --version and --list-profiles should work without -e and -p.
469    if "--version" in sys.argv[1:]:
470        _show_version()
471    if "--list-profiles" in sys.argv[1:]:
472        _configure_logging("--verbose" in sys.argv[1:])
473        _list_profiles()
474
475    parser = _build_parser()
476    args = parser.parse_args()
477
478    _configure_logging(args.verbose)
479    if not args.quiet:
480        _print_startup_banner(include_logo=not args.no_logo)
481
482    # Resolve prompt (may be a file reference).
483    prompt = _resolve_prompt(args.prompt)
484    if not prompt:
485        print("ERROR: Investigation prompt must not be empty.", file=sys.stderr)
486        raise SystemExit(EXIT_FAILURE)
487
488    date_range = _resolve_date_range(args.date_start, args.date_end)
489
490    # Resolve only explicit output directories.  When omitted, the automation
491    # engine resolves reports into the created case's reports directory.
492    output_dir = Path(args.output).resolve() if args.output else None
493
494    # Lazy-import the automation engine to avoid loading Flask.
495    from app.automation.engine import AutomationRequest, run_automation
496
497    request = AutomationRequest(
498        evidence_path=args.evidence,
499        prompt=prompt,
500        output_dir=output_dir,
501        profile_name=args.profile,
502        config_path=args.config,
503        case_name=args.case_name,
504        skip_hashing=args.skip_hashing,
505        date_range=date_range,
506    )
507
508    progress_callback = _make_progress_callback(args.quiet)
509
510    try:
511        result = run_automation(request, progress_callback=progress_callback)
512    except KeyboardInterrupt:
513        print("\nAborted by user.", file=sys.stderr)
514        raise SystemExit(EXIT_FAILURE) from None
515    except Exception as exc:
516        logging.getLogger(__name__).debug("Unhandled exception", exc_info=True)
517        print(f"ERROR: {exc}", file=sys.stderr)
518        if args.verbose:
519            import traceback
520            traceback.print_exc(file=sys.stderr)
521        raise SystemExit(EXIT_FAILURE) from None
522
523    _print_summary(result)
524
525    if not result.success:
526        raise SystemExit(EXIT_FAILURE)
527    if result.warnings:
528        raise SystemExit(EXIT_PARTIAL)
529    raise SystemExit(EXIT_SUCCESS)

Parse arguments and run the AIFT automation pipeline.

This is the CLI entry point. It validates the Python version, parses command-line arguments, resolves the investigation prompt, builds an AutomationRequest, calls run_automation(), and prints the summary.

Raises:
  • SystemExit: With exit code 0 (success), 1 (failure), or 2 (partial success with warnings).