hashan-77 commited on
Commit
7aa4f7c
·
verified ·
1 Parent(s): 7a33905

Deploy code agent from GitHub Actions

Browse files
Files changed (1) hide show
  1. app.py +873 -11
app.py CHANGED
@@ -1,9 +1,11 @@
1
  from fastapi import FastAPI
2
- from pydantic import BaseModel
3
  from transformers import AutoTokenizer, AutoModelForCausalLM, AutoConfig
 
4
  import os
5
  import torch
6
  import re
 
7
 
8
  HF_MODEL = os.getenv("HF_MODEL", "Qwen/Qwen2.5-Coder-1.5B-Instruct")
9
 
@@ -26,6 +28,56 @@ class CodeRepairRequest(BaseModel):
26
  exit_code: int | None = None
27
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  @app.get("/")
30
  def health_check():
31
  return {
@@ -33,7 +85,9 @@ def health_check():
33
  "status": "running",
34
  "llm_enabled": True,
35
  "llm_mode": "local-transformers",
36
- "model": HF_MODEL
 
 
37
  }
38
 
39
 
@@ -383,25 +437,30 @@ Keep the answer concise.
383
  """
384
 
385
 
386
- def call_llm(prompt: str):
 
 
 
 
 
387
  active_tokenizer, active_model = load_model()
388
 
389
  messages = [
390
  {
391
  "role": "system",
392
- "content": "You are a careful code repair assistant. Return only the final repair guidance."
393
  },
394
  {
395
  "role": "user",
396
- "content": prompt
397
- }
398
  ]
399
 
400
  if hasattr(active_tokenizer, "apply_chat_template"):
401
  formatted_prompt = active_tokenizer.apply_chat_template(
402
  messages,
403
  tokenize=False,
404
- add_generation_prompt=True
405
  )
406
  else:
407
  formatted_prompt = prompt
@@ -410,14 +469,14 @@ def call_llm(prompt: str):
410
  formatted_prompt,
411
  return_tensors="pt",
412
  truncation=True,
413
- max_length=1024
414
  )
415
 
416
  outputs = active_model.generate(
417
  **inputs,
418
- max_new_tokens=256,
419
  do_sample=False,
420
- pad_token_id=active_tokenizer.eos_token_id
421
  )
422
 
423
  generated_text = active_tokenizer.decode(outputs[0], skip_special_tokens=True)
@@ -542,6 +601,809 @@ def clean_output(text: str, request: CodeRepairRequest):
542
 
543
  return cleaned
544
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
545
 
546
  @app.post("/suggest-code-fix")
547
  def suggest_code_fix(request: CodeRepairRequest):
@@ -582,4 +1444,4 @@ def suggest_code_fix(request: CodeRepairRequest):
582
 
583
  except Exception as error:
584
  fallback_result["llm_error"] = repr(error)
585
- return fallback_result
 
1
  from fastapi import FastAPI
2
+ from pydantic import BaseModel, Field
3
  from transformers import AutoTokenizer, AutoModelForCausalLM, AutoConfig
4
+ import ast
5
  import os
6
  import torch
7
  import re
8
+ from collections import Counter
9
 
10
  HF_MODEL = os.getenv("HF_MODEL", "Qwen/Qwen2.5-Coder-1.5B-Instruct")
11
 
 
28
  exit_code: int | None = None
29
 
30
 
31
+ class SourceFileInput(BaseModel):
32
+ path: str
33
+ content: str
34
+ truncated: bool = False
35
+ original_chars: int = 0
36
+
37
+
38
+ class SourceReviewRequest(BaseModel):
39
+ project_type: str
40
+ has_tests: bool = False
41
+ files: list[SourceFileInput] = Field(default_factory=list)
42
+ discovered_files_count: int = 0
43
+ submitted_files_count: int = 0
44
+ submitted_chars: int = 0
45
+ truncated_files_count: int = 0
46
+ omitted_files_count: int = 0
47
+ read_error_files_count: int = 0
48
+
49
+
50
+ class SourceFinding(BaseModel):
51
+ id: str
52
+ file_path: str | None = None
53
+ line: int | None = None
54
+ category: str
55
+ severity: str
56
+ confidence: str
57
+ title: str
58
+ evidence: str
59
+ impact: str
60
+ recommendation: str
61
+
62
+
63
+ class SourceReviewResponse(BaseModel):
64
+ agent: str
65
+ mode: str
66
+ status: str
67
+ summary: str
68
+ risk_level: str
69
+ release_recommendation: str
70
+ reviewed_files_count: int
71
+ findings_count: int
72
+ severity_summary: dict[str, int]
73
+ category_summary: dict[str, int]
74
+ findings: list[SourceFinding] = Field(default_factory=list)
75
+ warnings: list[str] = Field(default_factory=list)
76
+ limitations: list[str] = Field(default_factory=list)
77
+ verification: str
78
+ llm_error: str | None = None
79
+
80
+
81
  @app.get("/")
82
  def health_check():
83
  return {
 
85
  "status": "running",
86
  "llm_enabled": True,
87
  "llm_mode": "local-transformers",
88
+ "model": HF_MODEL,
89
+ "capabilities": ["source-review", "code-repair"],
90
+ "endpoints": ["/review-source", "/suggest-code-fix"],
91
  }
92
 
93
 
 
437
  """
438
 
439
 
440
+ def call_llm(
441
+ prompt: str,
442
+ system_prompt: str = "You are a careful code repair assistant. Return only the final repair guidance.",
443
+ max_input_tokens: int = 1024,
444
+ max_new_tokens: int = 256,
445
+ ):
446
  active_tokenizer, active_model = load_model()
447
 
448
  messages = [
449
  {
450
  "role": "system",
451
+ "content": system_prompt,
452
  },
453
  {
454
  "role": "user",
455
+ "content": prompt,
456
+ },
457
  ]
458
 
459
  if hasattr(active_tokenizer, "apply_chat_template"):
460
  formatted_prompt = active_tokenizer.apply_chat_template(
461
  messages,
462
  tokenize=False,
463
+ add_generation_prompt=True,
464
  )
465
  else:
466
  formatted_prompt = prompt
 
469
  formatted_prompt,
470
  return_tensors="pt",
471
  truncation=True,
472
+ max_length=max_input_tokens,
473
  )
474
 
475
  outputs = active_model.generate(
476
  **inputs,
477
+ max_new_tokens=max_new_tokens,
478
  do_sample=False,
479
+ pad_token_id=active_tokenizer.eos_token_id,
480
  )
481
 
482
  generated_text = active_tokenizer.decode(outputs[0], skip_special_tokens=True)
 
601
 
602
  return cleaned
603
 
604
+ SEVERITY_ORDER = {
605
+ "CRITICAL": 0,
606
+ "HIGH": 1,
607
+ "MEDIUM": 2,
608
+ "LOW": 3,
609
+ "INFO": 4,
610
+ }
611
+
612
+ SECRET_NAME_PATTERN = re.compile(
613
+ r"(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|access[_-]?key)",
614
+ re.IGNORECASE,
615
+ )
616
+
617
+ PLACEHOLDER_SECRET_VALUES = {
618
+ "",
619
+ "change-me",
620
+ "changeme",
621
+ "example",
622
+ "placeholder",
623
+ "secret",
624
+ "password",
625
+ "token",
626
+ "your-secret",
627
+ "your-token",
628
+ }
629
+
630
+
631
+ def dotted_name(node):
632
+ if isinstance(node, ast.Name):
633
+ return node.id
634
+
635
+ if isinstance(node, ast.Attribute):
636
+ parent = dotted_name(node.value)
637
+ return f"{parent}.{node.attr}" if parent else node.attr
638
+
639
+ return ""
640
+
641
+
642
+ def line_evidence(content, line_number, fallback):
643
+ lines = content.splitlines()
644
+
645
+ if line_number and 0 < line_number <= len(lines):
646
+ value = lines[line_number - 1].strip()
647
+ if value:
648
+ return value[:240]
649
+
650
+ return fallback
651
+
652
+
653
+ def is_placeholder_secret(value):
654
+ normalized = str(value).strip().lower()
655
+
656
+ if normalized in PLACEHOLDER_SECRET_VALUES:
657
+ return True
658
+
659
+ return any(
660
+ marker in normalized
661
+ for marker in ("${", "{{", "env.", "process.env", "os.getenv", "system.getenv")
662
+ )
663
+
664
+
665
+ def assignment_target_names(node):
666
+ names = []
667
+
668
+ if isinstance(node, ast.Name):
669
+ names.append(node.id)
670
+ elif isinstance(node, (ast.Tuple, ast.List)):
671
+ for item in node.elts:
672
+ names.extend(assignment_target_names(item))
673
+ elif isinstance(node, ast.Attribute):
674
+ names.append(node.attr)
675
+
676
+ return names
677
+
678
+
679
+ def keyword_value(call, keyword_name):
680
+ for keyword in call.keywords:
681
+ if keyword.arg == keyword_name:
682
+ return keyword.value
683
+
684
+ return None
685
+
686
+
687
+ def is_true_literal(node):
688
+ return isinstance(node, ast.Constant) and node.value is True
689
+
690
+
691
+ def is_false_literal(node):
692
+ return isinstance(node, ast.Constant) and node.value is False
693
+
694
+
695
+ def add_finding(
696
+ findings,
697
+ file_path,
698
+ line,
699
+ category,
700
+ severity,
701
+ confidence,
702
+ title,
703
+ evidence,
704
+ impact,
705
+ recommendation,
706
+ ):
707
+ findings.append(
708
+ {
709
+ "file_path": file_path,
710
+ "line": line,
711
+ "category": category,
712
+ "severity": severity,
713
+ "confidence": confidence,
714
+ "title": title,
715
+ "evidence": evidence,
716
+ "impact": impact,
717
+ "recommendation": recommendation,
718
+ }
719
+ )
720
+
721
+
722
+ def analyze_python_source(source_file, findings, warnings):
723
+ path = source_file.path
724
+ content = source_file.content
725
+
726
+ try:
727
+ tree = ast.parse(content, filename=path)
728
+ except SyntaxError as error:
729
+ if source_file.truncated:
730
+ warnings.append(
731
+ f"{path} could not be fully parsed because the submitted content was truncated."
732
+ )
733
+ return
734
+
735
+ add_finding(
736
+ findings,
737
+ path,
738
+ error.lineno,
739
+ "correctness",
740
+ "HIGH",
741
+ "HIGH",
742
+ "Python syntax error",
743
+ error.msg,
744
+ "The module cannot be imported or executed successfully.",
745
+ "Correct the syntax error and rerun Stitch QA before deployment.",
746
+ )
747
+ return
748
+
749
+ for node in ast.walk(tree):
750
+ if isinstance(node, (ast.Assign, ast.AnnAssign)):
751
+ value = node.value
752
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
753
+ names = []
754
+
755
+ for target in targets:
756
+ names.extend(assignment_target_names(target))
757
+
758
+ if (
759
+ isinstance(value, ast.Constant)
760
+ and isinstance(value.value, str)
761
+ and len(value.value.strip()) >= 6
762
+ and any(SECRET_NAME_PATTERN.search(name) for name in names)
763
+ and not is_placeholder_secret(value.value)
764
+ ):
765
+ add_finding(
766
+ findings,
767
+ path,
768
+ getattr(node, "lineno", None),
769
+ "security",
770
+ "HIGH",
771
+ "HIGH",
772
+ "Possible hardcoded credential",
773
+ "A credential-like variable is assigned a literal string value.",
774
+ "Hardcoded credentials can be exposed through source control, logs, or package distribution.",
775
+ "Move the value to a secret manager or environment variable and rotate any exposed credential.",
776
+ )
777
+
778
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
779
+ defaults = list(node.args.defaults) + [
780
+ item for item in node.args.kw_defaults if item is not None
781
+ ]
782
+
783
+ if any(isinstance(default, (ast.List, ast.Dict, ast.Set)) for default in defaults):
784
+ add_finding(
785
+ findings,
786
+ path,
787
+ getattr(node, "lineno", None),
788
+ "reliability",
789
+ "MEDIUM",
790
+ "HIGH",
791
+ "Mutable function default",
792
+ f"Function `{node.name}` uses a mutable default argument.",
793
+ "State can leak between calls and create difficult-to-reproduce defects.",
794
+ "Use None as the default and create the mutable object inside the function.",
795
+ )
796
+
797
+ if isinstance(node, ast.ExceptHandler):
798
+ if node.type is None:
799
+ add_finding(
800
+ findings,
801
+ path,
802
+ getattr(node, "lineno", None),
803
+ "reliability",
804
+ "MEDIUM",
805
+ "HIGH",
806
+ "Bare exception handler",
807
+ line_evidence(content, getattr(node, "lineno", None), "A bare except handler was detected."),
808
+ "SystemExit, KeyboardInterrupt, and unexpected defects can be swallowed.",
809
+ "Catch only the expected exception types and preserve actionable error context.",
810
+ )
811
+
812
+ if node.body and all(isinstance(item, ast.Pass) for item in node.body):
813
+ add_finding(
814
+ findings,
815
+ path,
816
+ getattr(node, "lineno", None),
817
+ "error-handling",
818
+ "MEDIUM",
819
+ "HIGH",
820
+ "Exception silently ignored",
821
+ line_evidence(content, getattr(node, "lineno", None), "An exception handler contains only pass."),
822
+ "Failures can be hidden and leave the application in an unknown state.",
823
+ "Handle the exception explicitly or log and re-raise it with safe context.",
824
+ )
825
+
826
+ if not isinstance(node, ast.Call):
827
+ continue
828
+
829
+ function_name = dotted_name(node.func)
830
+ line = getattr(node, "lineno", None)
831
+ evidence = line_evidence(content, line, function_name)
832
+
833
+ if function_name in {"eval", "exec", "builtins.eval", "builtins.exec"}:
834
+ add_finding(
835
+ findings,
836
+ path,
837
+ line,
838
+ "security",
839
+ "HIGH",
840
+ "HIGH",
841
+ "Dynamic code execution",
842
+ evidence,
843
+ "Untrusted input can lead to arbitrary code execution.",
844
+ "Remove dynamic execution or replace it with a strict parser and an allowlisted operation set.",
845
+ )
846
+
847
+ if function_name == "os.system":
848
+ add_finding(
849
+ findings,
850
+ path,
851
+ line,
852
+ "security",
853
+ "HIGH",
854
+ "HIGH",
855
+ "Shell command execution through os.system",
856
+ evidence,
857
+ "User-controlled values can create operating-system command injection.",
858
+ "Use subprocess with an argument list, shell disabled, validated inputs, and least privilege.",
859
+ )
860
+
861
+ if function_name.startswith("subprocess.") and is_true_literal(
862
+ keyword_value(node, "shell")
863
+ ):
864
+ add_finding(
865
+ findings,
866
+ path,
867
+ line,
868
+ "security",
869
+ "HIGH",
870
+ "HIGH",
871
+ "Subprocess executed with shell enabled",
872
+ evidence,
873
+ "Shell interpretation increases command-injection risk.",
874
+ "Disable shell execution and pass validated command arguments as a list.",
875
+ )
876
+
877
+ if function_name in {"pickle.load", "pickle.loads"}:
878
+ add_finding(
879
+ findings,
880
+ path,
881
+ line,
882
+ "security",
883
+ "HIGH",
884
+ "HIGH",
885
+ "Unsafe pickle deserialization",
886
+ evidence,
887
+ "Loading attacker-controlled pickle data can execute arbitrary code.",
888
+ "Use a non-executable serialization format and validate data before processing it.",
889
+ )
890
+
891
+ if function_name == "yaml.load":
892
+ loader = keyword_value(node, "Loader")
893
+ loader_name = dotted_name(loader) if loader is not None else ""
894
+
895
+ if loader_name not in {"yaml.SafeLoader", "yaml.CSafeLoader", "SafeLoader", "CSafeLoader"}:
896
+ add_finding(
897
+ findings,
898
+ path,
899
+ line,
900
+ "security",
901
+ "HIGH",
902
+ "HIGH",
903
+ "Unsafe YAML loading",
904
+ evidence,
905
+ "Unsafe YAML constructors can instantiate arbitrary Python objects.",
906
+ "Use yaml.safe_load or explicitly select SafeLoader.",
907
+ )
908
+
909
+ if function_name.startswith("requests.") and is_false_literal(
910
+ keyword_value(node, "verify")
911
+ ):
912
+ add_finding(
913
+ findings,
914
+ path,
915
+ line,
916
+ "security",
917
+ "HIGH",
918
+ "HIGH",
919
+ "TLS certificate verification disabled",
920
+ evidence,
921
+ "Network traffic can be intercepted by an attacker presenting an untrusted certificate.",
922
+ "Enable certificate verification and configure a trusted CA bundle when required.",
923
+ )
924
+
925
+ if function_name in {"hashlib.md5", "hashlib.sha1"}:
926
+ add_finding(
927
+ findings,
928
+ path,
929
+ line,
930
+ "security",
931
+ "MEDIUM",
932
+ "MEDIUM",
933
+ "Weak cryptographic hash",
934
+ evidence,
935
+ "MD5 and SHA-1 are unsuitable for security-sensitive integrity or credential protection.",
936
+ "Use a modern hash such as SHA-256, or a password-specific algorithm such as Argon2 or bcrypt.",
937
+ )
938
+
939
+ if function_name.endswith(".run") and is_true_literal(keyword_value(node, "debug")):
940
+ add_finding(
941
+ findings,
942
+ path,
943
+ line,
944
+ "security",
945
+ "MEDIUM",
946
+ "MEDIUM",
947
+ "Debug mode explicitly enabled",
948
+ evidence,
949
+ "Production debug mode can expose sensitive stack traces or interactive debugging features.",
950
+ "Disable debug mode in deployment and control it through environment-specific configuration.",
951
+ )
952
+
953
+ if len(content.splitlines()) > 800:
954
+ add_finding(
955
+ findings,
956
+ path,
957
+ 1,
958
+ "maintainability",
959
+ "LOW",
960
+ "MEDIUM",
961
+ "Large source file",
962
+ f"The submitted file contains {len(content.splitlines())} lines.",
963
+ "Very large modules are harder to review, test, and maintain safely.",
964
+ "Split unrelated responsibilities into smaller cohesive modules while preserving behavior.",
965
+ )
966
+
967
+
968
+ def analyze_java_source(source_file, findings):
969
+ path = source_file.path
970
+ content = source_file.content
971
+ lines = content.splitlines()
972
+
973
+ for index, raw_line in enumerate(lines, start=1):
974
+ line = raw_line.strip()
975
+ secret_match = re.search(
976
+ r'(?i)\b(?:String\s+)?([A-Za-z0-9_]*(?:password|passwd|secret|token|apiKey|api_key|privateKey|accessKey)[A-Za-z0-9_]*)\s*=\s*"([^"]{6,})"',
977
+ line,
978
+ )
979
+
980
+ if secret_match and not is_placeholder_secret(secret_match.group(2)):
981
+ add_finding(
982
+ findings,
983
+ path,
984
+ index,
985
+ "security",
986
+ "HIGH",
987
+ "HIGH",
988
+ "Possible hardcoded credential",
989
+ "A credential-like Java variable is assigned a literal string value.",
990
+ "The credential can be exposed through source control, build artifacts, or logs.",
991
+ "Load the value from a protected secret store or environment variable and rotate exposed credentials.",
992
+ )
993
+
994
+ if "Runtime.getRuntime().exec" in line:
995
+ add_finding(
996
+ findings,
997
+ path,
998
+ index,
999
+ "security",
1000
+ "HIGH",
1001
+ "HIGH",
1002
+ "Runtime command execution",
1003
+ line[:240],
1004
+ "Unvalidated command content can lead to operating-system command injection.",
1005
+ "Use a fixed command with validated arguments and avoid passing user-controlled shell content.",
1006
+ )
1007
+
1008
+ if "new ProcessBuilder" in line:
1009
+ add_finding(
1010
+ findings,
1011
+ path,
1012
+ index,
1013
+ "security",
1014
+ "MEDIUM",
1015
+ "MEDIUM",
1016
+ "External process creation requires validation",
1017
+ line[:240],
1018
+ "Untrusted process arguments can create command execution or privilege risks.",
1019
+ "Use allowlisted commands, validated argument arrays, controlled working directories, and least privilege.",
1020
+ )
1021
+
1022
+ if re.search(r"\bexecute(?:Query|Update)?\s*\([^)]*\+", line):
1023
+ add_finding(
1024
+ findings,
1025
+ path,
1026
+ index,
1027
+ "security",
1028
+ "HIGH",
1029
+ "MEDIUM",
1030
+ "Possible SQL query concatenation",
1031
+ line[:240],
1032
+ "Concatenated untrusted values can allow SQL injection.",
1033
+ "Use PreparedStatement parameters or an ORM parameter-binding API.",
1034
+ )
1035
+
1036
+ if re.search(r'MessageDigest\.getInstance\(\s*"(?:MD5|SHA-1)"', line, re.IGNORECASE):
1037
+ add_finding(
1038
+ findings,
1039
+ path,
1040
+ index,
1041
+ "security",
1042
+ "MEDIUM",
1043
+ "HIGH",
1044
+ "Weak cryptographic hash",
1045
+ line[:240],
1046
+ "MD5 and SHA-1 are unsuitable for security-sensitive integrity or credential protection.",
1047
+ "Use SHA-256 or stronger, and use a password-specific algorithm for password storage.",
1048
+ )
1049
+
1050
+ if ".printStackTrace()" in line:
1051
+ add_finding(
1052
+ findings,
1053
+ path,
1054
+ index,
1055
+ "error-handling",
1056
+ "LOW",
1057
+ "HIGH",
1058
+ "Stack trace written directly",
1059
+ line[:240],
1060
+ "Raw stack traces can expose implementation details and bypass structured logging.",
1061
+ "Use the project logger with controlled context and avoid exposing sensitive details to clients.",
1062
+ )
1063
+
1064
+ if "System.out.print" in line or "System.err.print" in line:
1065
+ add_finding(
1066
+ findings,
1067
+ path,
1068
+ index,
1069
+ "maintainability",
1070
+ "LOW",
1071
+ "HIGH",
1072
+ "Direct console output in application code",
1073
+ line[:240],
1074
+ "Console output is difficult to classify, filter, and correlate in production.",
1075
+ "Use structured application logging with an appropriate log level.",
1076
+ )
1077
+
1078
+ if re.search(r"@CrossOrigin\s*\([^)]*\*", line) or re.search(
1079
+ r"allowedOrigins?\s*\([^)]*\*", line, re.IGNORECASE
1080
+ ):
1081
+ add_finding(
1082
+ findings,
1083
+ path,
1084
+ index,
1085
+ "security",
1086
+ "MEDIUM",
1087
+ "HIGH",
1088
+ "Wildcard cross-origin access",
1089
+ line[:240],
1090
+ "Unrestricted origins can expose APIs to untrusted web applications.",
1091
+ "Allow only explicitly trusted origins and review credential-sharing settings.",
1092
+ )
1093
+
1094
+ if re.search(r"HostnameVerifier.*->\s*true", line) or "ALLOW_ALL_HOSTNAME_VERIFIER" in line:
1095
+ add_finding(
1096
+ findings,
1097
+ path,
1098
+ index,
1099
+ "security",
1100
+ "CRITICAL",
1101
+ "HIGH",
1102
+ "TLS hostname verification bypass",
1103
+ line[:240],
1104
+ "An attacker can impersonate a remote service even when TLS is used.",
1105
+ "Remove the permissive verifier and use the platform default hostname and certificate validation.",
1106
+ )
1107
+
1108
+ for match in re.finditer(r"catch\s*\([^)]*\)\s*\{\s*\}", content, re.DOTALL):
1109
+ line = content.count("\n", 0, match.start()) + 1
1110
+ add_finding(
1111
+ findings,
1112
+ path,
1113
+ line,
1114
+ "error-handling",
1115
+ "MEDIUM",
1116
+ "HIGH",
1117
+ "Empty Java catch block",
1118
+ "A catch block contains no handling logic.",
1119
+ "The application can hide failures and continue with invalid state.",
1120
+ "Handle the expected exception, preserve safe diagnostic context, or rethrow it appropriately.",
1121
+ )
1122
+
1123
+ if len(lines) > 800:
1124
+ add_finding(
1125
+ findings,
1126
+ path,
1127
+ 1,
1128
+ "maintainability",
1129
+ "LOW",
1130
+ "MEDIUM",
1131
+ "Large source file",
1132
+ f"The submitted file contains {len(lines)} lines.",
1133
+ "Very large classes are harder to review, test, and maintain safely.",
1134
+ "Separate unrelated responsibilities into smaller cohesive classes while preserving behavior.",
1135
+ )
1136
+
1137
+
1138
+ def deduplicate_findings(findings):
1139
+ unique = {}
1140
+
1141
+ for finding in findings:
1142
+ key = (
1143
+ finding.get("file_path"),
1144
+ finding.get("line"),
1145
+ finding.get("title"),
1146
+ )
1147
+ unique[key] = finding
1148
+
1149
+ ordered = sorted(
1150
+ unique.values(),
1151
+ key=lambda item: (
1152
+ SEVERITY_ORDER.get(item.get("severity"), 99),
1153
+ str(item.get("file_path") or ""),
1154
+ item.get("line") or 0,
1155
+ item.get("title") or "",
1156
+ ),
1157
+ )
1158
+
1159
+ for index, finding in enumerate(ordered, start=1):
1160
+ finding["id"] = f"SQ-SRC-{index:03d}"
1161
+
1162
+ return ordered
1163
+
1164
+
1165
+ def build_severity_summary(findings):
1166
+ summary = {
1167
+ "CRITICAL": 0,
1168
+ "HIGH": 0,
1169
+ "MEDIUM": 0,
1170
+ "LOW": 0,
1171
+ "INFO": 0,
1172
+ }
1173
+
1174
+ for finding in findings:
1175
+ severity = finding.get("severity", "INFO")
1176
+ summary[severity] = summary.get(severity, 0) + 1
1177
+
1178
+ return summary
1179
+
1180
+
1181
+ def build_category_summary(findings):
1182
+ return dict(sorted(Counter(finding.get("category", "other") for finding in findings).items()))
1183
+
1184
+
1185
+ def build_risk_level(severity_summary):
1186
+ for severity in ("CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"):
1187
+ if severity_summary.get(severity, 0):
1188
+ return severity
1189
+
1190
+ return "LOW"
1191
+
1192
+
1193
+ def build_release_recommendation(risk_level, partial):
1194
+ if risk_level in {"CRITICAL", "HIGH"}:
1195
+ return "BLOCK_RELEASE"
1196
+
1197
+ if risk_level == "MEDIUM":
1198
+ return "REVIEW_REQUIRED"
1199
+
1200
+ if partial:
1201
+ return "MANUAL_REVIEW_REQUIRED"
1202
+
1203
+ return "READY_WITH_CAUTION"
1204
+
1205
+
1206
+ def fallback_source_summary(request, findings, risk_level, release_recommendation):
1207
+ if not findings:
1208
+ return (
1209
+ f"Agent 3 reviewed {len(request.files)} application source files and did not identify "
1210
+ "a blocking issue with the implemented checks. This result does not replace manual, "
1211
+ "dependency, architecture, or runtime security review."
1212
+ )
1213
+
1214
+ severity_summary = build_severity_summary(findings)
1215
+ return (
1216
+ f"Agent 3 reviewed {len(request.files)} application source files and reported "
1217
+ f"{len(findings)} findings. Highest risk is {risk_level}. "
1218
+ f"Critical: {severity_summary.get('CRITICAL', 0)}, High: {severity_summary.get('HIGH', 0)}, "
1219
+ f"Medium: {severity_summary.get('MEDIUM', 0)}, Low: {severity_summary.get('LOW', 0)}. "
1220
+ f"Release recommendation: {release_recommendation}."
1221
+ )
1222
+
1223
+
1224
+ def build_source_summary_prompt(request, findings, risk_level, release_recommendation):
1225
+ finding_lines = []
1226
+
1227
+ for finding in findings[:20]:
1228
+ location = finding.get("file_path") or "Project-level"
1229
+ if finding.get("line"):
1230
+ location = f"{location}:{finding.get('line')}"
1231
+ finding_lines.append(
1232
+ f"- [{finding.get('severity')}] {finding.get('title')} at {location}: "
1233
+ f"{finding.get('impact')}"
1234
+ )
1235
+
1236
+ findings_text = "\n".join(finding_lines) or "- No findings from the implemented checks."
1237
+
1238
+ return f"""
1239
+ Create a concise professional pre-release source-code QA summary using only the supplied facts.
1240
+
1241
+ Project type: {request.project_type}
1242
+ Reviewed files: {len(request.files)}
1243
+ Has detected tests: {request.has_tests}
1244
+ Highest risk: {risk_level}
1245
+ Release recommendation: {release_recommendation}
1246
+
1247
+ Confirmed findings:
1248
+ {findings_text}
1249
+
1250
+ State what was reviewed, the most important confirmed risks, and the required next action.
1251
+ Do not invent findings, files, runtime results, or successful deployment claims.
1252
+ Return one short paragraph only.
1253
+ """
1254
+
1255
+
1256
+ def clean_source_summary(text):
1257
+ cleaned = remove_prompt_leak(text)
1258
+
1259
+ if not cleaned:
1260
+ return None
1261
+
1262
+ cleaned = re.sub(r"\n{2,}", " ", cleaned).strip()
1263
+
1264
+ if len(cleaned) < 40:
1265
+ return None
1266
+
1267
+ blocked_phrases = [
1268
+ "create a concise professional",
1269
+ "confirmed findings:",
1270
+ "do not invent findings",
1271
+ "return one short paragraph",
1272
+ "system you are",
1273
+ "user you are",
1274
+ ]
1275
+
1276
+ if any(phrase in cleaned.lower() for phrase in blocked_phrases):
1277
+ return None
1278
+
1279
+ return cleaned[:1600]
1280
+
1281
+
1282
+ def review_source_request(request: SourceReviewRequest):
1283
+ findings = []
1284
+ warnings = []
1285
+ limitations = [
1286
+ "This review uses targeted static checks and an AI summary; it does not prove the absence of defects or vulnerabilities.",
1287
+ "Dependency vulnerabilities, infrastructure configuration, business-rule correctness, and runtime behavior require separate evidence.",
1288
+ ]
1289
+
1290
+ for source_file in request.files:
1291
+ suffix = os.path.splitext(source_file.path.lower())[1]
1292
+
1293
+ if suffix == ".py":
1294
+ analyze_python_source(source_file, findings, warnings)
1295
+ elif suffix == ".java":
1296
+ analyze_java_source(source_file, findings)
1297
+ else:
1298
+ warnings.append(f"Unsupported source extension skipped by Agent 3: {source_file.path}")
1299
+
1300
+ if source_file.truncated:
1301
+ warnings.append(
1302
+ f"{source_file.path} was truncated before review; findings may not cover the entire file."
1303
+ )
1304
+
1305
+ if not request.has_tests:
1306
+ add_finding(
1307
+ findings,
1308
+ None,
1309
+ None,
1310
+ "testing",
1311
+ "MEDIUM",
1312
+ "HIGH",
1313
+ "No automated tests detected",
1314
+ "The project scan did not detect a compatible automated test suite.",
1315
+ "Source review alone cannot verify runtime behavior, integrations, regressions, or business rules.",
1316
+ "Add focused automated tests for critical flows, validation, error paths, and security-sensitive behavior.",
1317
+ )
1318
+
1319
+ findings = deduplicate_findings(findings)
1320
+ severity_summary = build_severity_summary(findings)
1321
+ category_summary = build_category_summary(findings)
1322
+ risk_level = build_risk_level(severity_summary)
1323
+ partial = any(
1324
+ (
1325
+ request.truncated_files_count,
1326
+ request.omitted_files_count,
1327
+ request.read_error_files_count,
1328
+ )
1329
+ )
1330
+ release_recommendation = build_release_recommendation(risk_level, partial)
1331
+
1332
+ if request.omitted_files_count:
1333
+ limitations.append(
1334
+ f"{request.omitted_files_count} discovered source files were omitted by client-side review limits."
1335
+ )
1336
+
1337
+ if request.read_error_files_count:
1338
+ limitations.append(
1339
+ f"{request.read_error_files_count} source files could not be read by the CLI."
1340
+ )
1341
+
1342
+ if request.truncated_files_count:
1343
+ limitations.append(
1344
+ f"{request.truncated_files_count} source files were partially reviewed because of size limits."
1345
+ )
1346
+
1347
+ status = "PARTIAL" if partial else "COMPLETED"
1348
+ mode = "rule-based"
1349
+ llm_error = None
1350
+ summary = fallback_source_summary(
1351
+ request,
1352
+ findings,
1353
+ risk_level,
1354
+ release_recommendation,
1355
+ )
1356
+
1357
+ try:
1358
+ prompt = build_source_summary_prompt(
1359
+ request,
1360
+ findings,
1361
+ risk_level,
1362
+ release_recommendation,
1363
+ )
1364
+ llm_text = call_llm(
1365
+ prompt,
1366
+ system_prompt=(
1367
+ "You are Agent 3 of Stitch QA, a careful pre-release source-code QA reviewer. "
1368
+ "Summarize only confirmed supplied findings and never claim complete safety."
1369
+ ),
1370
+ max_input_tokens=1536,
1371
+ max_new_tokens=220,
1372
+ )
1373
+ cleaned_summary = clean_source_summary(llm_text)
1374
+
1375
+ if cleaned_summary:
1376
+ summary = cleaned_summary
1377
+ mode = "hybrid"
1378
+ except Exception as error:
1379
+ llm_error = repr(error)
1380
+
1381
+ return {
1382
+ "agent": "code-agent",
1383
+ "mode": mode,
1384
+ "status": status,
1385
+ "summary": summary,
1386
+ "risk_level": risk_level,
1387
+ "release_recommendation": release_recommendation,
1388
+ "reviewed_files_count": len(request.files),
1389
+ "findings_count": len(findings),
1390
+ "severity_summary": severity_summary,
1391
+ "category_summary": category_summary,
1392
+ "findings": findings,
1393
+ "warnings": list(dict.fromkeys(warnings)),
1394
+ "limitations": limitations,
1395
+ "verification": (
1396
+ "Manually validate confirmed findings, apply the smallest safe fixes, add or run tests, "
1397
+ "and rerun Stitch QA before deployment."
1398
+ ),
1399
+ "llm_error": llm_error,
1400
+ }
1401
+
1402
+
1403
+ @app.post("/review-source", response_model=SourceReviewResponse)
1404
+ def review_source(request: SourceReviewRequest):
1405
+ return review_source_request(request)
1406
+
1407
 
1408
  @app.post("/suggest-code-fix")
1409
  def suggest_code_fix(request: CodeRepairRequest):
 
1444
 
1445
  except Exception as error:
1446
  fallback_result["llm_error"] = repr(error)
1447
+ return fallback_result