Publish full source including logcat visual map engine
This commit is contained in:
parent
10902567d8
commit
ac609bc80c
132 changed files with 19071 additions and 2759 deletions
350
backend_apkaudit_export.go
Normal file
350
backend_apkaudit_export.go
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
package main
|
||||
|
||||
// Explorer entry viewer + findings export (JSON / CSV / SARIF) for the APK auditor.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
entryTextCap = 1 << 20 // 1 MB of text shown
|
||||
entryImageCap = 8 << 20 // 8 MB max image
|
||||
entryHexCap = 16 << 10 // 16 KB hex preview
|
||||
)
|
||||
|
||||
type APKEntryContent struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Kind string `json:"kind"` // text | image | binary
|
||||
Mime string `json:"mime"`
|
||||
Text string `json:"text"`
|
||||
Base64 string `json:"base64"`
|
||||
Hex string `json:"hex"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// ReadAPKEntry opens a single entry inside an APK and returns a viewable form:
|
||||
// text, base64-encoded image, or a hex preview for binaries.
|
||||
func (a *App) ReadAPKEntry(apkPath, entry string) (APKEntryContent, error) {
|
||||
if apkPath == "" || entry == "" {
|
||||
return APKEntryContent{}, fmt.Errorf("missing apk path or entry name")
|
||||
}
|
||||
if _, err := os.Stat(apkPath); err != nil {
|
||||
return APKEntryContent{}, fmt.Errorf("APK no longer available: %s", apkPath)
|
||||
}
|
||||
zr, err := zip.OpenReader(apkPath)
|
||||
if err != nil {
|
||||
return APKEntryContent{}, fmt.Errorf("open apk: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
var f *zip.File
|
||||
for _, e := range zr.File {
|
||||
if e.Name == entry {
|
||||
f = e
|
||||
break
|
||||
}
|
||||
}
|
||||
if f == nil {
|
||||
return APKEntryContent{}, fmt.Errorf("entry not found: %s", entry)
|
||||
}
|
||||
|
||||
res := APKEntryContent{Name: entry, Size: int64(f.UncompressedSize64), Mime: mimeForName(entry)}
|
||||
|
||||
if isImageName(entry) {
|
||||
data, _ := readEntryBytes(f, entryImageCap)
|
||||
res.Kind = "image"
|
||||
res.Base64 = base64.StdEncoding.EncodeToString(data)
|
||||
res.Truncated = int64(len(data)) < res.Size
|
||||
return res, nil
|
||||
}
|
||||
|
||||
data, truncated := readEntryBytes(f, entryTextCap)
|
||||
if isTextBytes(data) {
|
||||
res.Kind = "text"
|
||||
res.Text = string(data)
|
||||
res.Truncated = truncated
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// binary: hex preview of the first chunk
|
||||
preview := data
|
||||
if len(preview) > entryHexCap {
|
||||
preview = preview[:entryHexCap]
|
||||
truncated = true
|
||||
}
|
||||
res.Kind = "binary"
|
||||
res.Hex = hexDump(preview)
|
||||
res.Truncated = truncated || int64(len(data)) < res.Size
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ExportAudit writes the audit to disk in the requested format via a save
|
||||
// dialog and returns the chosen path ("" if the user cancelled).
|
||||
func (a *App) ExportAudit(audit APKAudit, format string) (string, error) {
|
||||
var content []byte
|
||||
var ext string
|
||||
switch strings.ToLower(format) {
|
||||
case "json":
|
||||
ext = "json"
|
||||
b, err := json.MarshalIndent(audit, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
content = b
|
||||
case "csv":
|
||||
ext = "csv"
|
||||
content = []byte(auditToCSV(audit))
|
||||
case "sarif":
|
||||
ext = "sarif"
|
||||
b, err := json.MarshalIndent(auditToSARIF(audit), "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
content = b
|
||||
default:
|
||||
return "", fmt.Errorf("unknown export format: %s", format)
|
||||
}
|
||||
|
||||
base := audit.PackageName
|
||||
if base == "" {
|
||||
base = strings.TrimSuffix(audit.FileName, filepath.Ext(audit.FileName))
|
||||
}
|
||||
if base == "" {
|
||||
base = "apk-audit"
|
||||
}
|
||||
path, err := a.SelectSaveFile(base + "-audit." + ext)
|
||||
if err != nil || path == "" {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||
return "", fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func auditToCSV(audit APKAudit) string {
|
||||
var buf bytes.Buffer
|
||||
w := csv.NewWriter(&buf)
|
||||
_ = w.Write([]string{"severity", "category", "title", "cwe", "masvs", "confidence", "file", "match"})
|
||||
for _, f := range audit.Findings {
|
||||
conf := strconv.Itoa(f.Confidence)
|
||||
if len(f.Matches) == 0 {
|
||||
_ = w.Write([]string{f.Severity, f.Category, f.Title, f.CWE, f.Masvs, conf, "", ""})
|
||||
continue
|
||||
}
|
||||
for _, m := range f.Matches {
|
||||
_ = w.Write([]string{f.Severity, f.Category, f.Title, f.CWE, f.Masvs, conf, m.File, m.Value})
|
||||
}
|
||||
}
|
||||
w.Flush()
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// auditToSARIF emits a minimal SARIF 2.1.0 log suitable for GitHub code scanning.
|
||||
func auditToSARIF(audit APKAudit) map[string]any {
|
||||
levelFor := func(sev string) string {
|
||||
switch sev {
|
||||
case "critical", "high":
|
||||
return "error"
|
||||
case "medium":
|
||||
return "warning"
|
||||
default:
|
||||
return "note"
|
||||
}
|
||||
}
|
||||
|
||||
seenRule := map[string]bool{}
|
||||
var rules []map[string]any
|
||||
var results []map[string]any
|
||||
|
||||
for _, f := range audit.Findings {
|
||||
if !seenRule[f.ID] {
|
||||
seenRule[f.ID] = true
|
||||
rule := map[string]any{
|
||||
"id": f.ID,
|
||||
"name": f.Title,
|
||||
"shortDescription": map[string]any{"text": f.Title},
|
||||
"fullDescription": map[string]any{"text": f.Description},
|
||||
"defaultConfiguration": map[string]any{"level": levelFor(f.Severity)},
|
||||
"properties": map[string]any{
|
||||
"cwe": f.CWE,
|
||||
"masvs": f.Masvs,
|
||||
"severity": f.Severity,
|
||||
},
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
|
||||
locations := []map[string]any{}
|
||||
for _, m := range f.Matches {
|
||||
uri := m.File
|
||||
if uri == "" {
|
||||
uri = audit.FileName
|
||||
}
|
||||
locations = append(locations, map[string]any{
|
||||
"physicalLocation": map[string]any{
|
||||
"artifactLocation": map[string]any{"uri": uri},
|
||||
},
|
||||
"message": map[string]any{"text": m.Value},
|
||||
})
|
||||
}
|
||||
if len(locations) == 0 {
|
||||
locations = append(locations, map[string]any{
|
||||
"physicalLocation": map[string]any{
|
||||
"artifactLocation": map[string]any{"uri": audit.FileName},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
results = append(results, map[string]any{
|
||||
"ruleId": f.ID,
|
||||
"level": levelFor(f.Severity),
|
||||
"message": map[string]any{"text": f.Title + " — " + f.Description},
|
||||
"locations": locations,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
||||
"version": "2.1.0",
|
||||
"runs": []map[string]any{{
|
||||
"tool": map[string]any{
|
||||
"driver": map[string]any{
|
||||
"name": "ATK APK Auditor",
|
||||
"informationUri": "https://github.com/jegly/ATK",
|
||||
"rules": rules,
|
||||
},
|
||||
},
|
||||
"properties": map[string]any{
|
||||
"package": audit.PackageName,
|
||||
"version": audit.VersionName,
|
||||
"score": audit.Score,
|
||||
"grade": audit.Grade,
|
||||
},
|
||||
"results": results,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func cleanStaleAuditTemps() {
|
||||
matches, _ := filepath.Glob(filepath.Join(os.TempDir(), "atk-audit-*.apk"))
|
||||
for _, m := range matches {
|
||||
os.Remove(m)
|
||||
}
|
||||
}
|
||||
|
||||
func readEntryBytes(f *zip.File, limit int) ([]byte, bool) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
defer rc.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(rc, int64(limit)+1))
|
||||
if err != nil {
|
||||
return data, false
|
||||
}
|
||||
if len(data) > limit {
|
||||
return data[:limit], true
|
||||
}
|
||||
return data, false
|
||||
}
|
||||
|
||||
func isTextBytes(data []byte) bool {
|
||||
if len(data) == 0 {
|
||||
return true
|
||||
}
|
||||
nonprint := 0
|
||||
for _, b := range data {
|
||||
if b == 0 {
|
||||
return false
|
||||
}
|
||||
if b < 0x09 || (b > 0x0d && b < 0x20) {
|
||||
nonprint++
|
||||
}
|
||||
}
|
||||
return float64(nonprint)/float64(len(data)) < 0.05
|
||||
}
|
||||
|
||||
func hexDump(data []byte) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(data); i += 16 {
|
||||
end := i + 16
|
||||
if end > len(data) {
|
||||
end = len(data)
|
||||
}
|
||||
row := data[i:end]
|
||||
b.WriteString(fmt.Sprintf("%08x ", i))
|
||||
for j := 0; j < 16; j++ {
|
||||
if j < len(row) {
|
||||
b.WriteString(fmt.Sprintf("%02x ", row[j]))
|
||||
} else {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
if j == 7 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
b.WriteString(" |")
|
||||
for _, c := range row {
|
||||
if c >= 0x20 && c < 0x7f {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
b.WriteByte('.')
|
||||
}
|
||||
}
|
||||
b.WriteString("|\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isImageName(name string) bool {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mimeForName(name string) string {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".bmp":
|
||||
return "image/bmp"
|
||||
case ".ico":
|
||||
return "image/x-icon"
|
||||
case ".svg":
|
||||
return "image/svg+xml"
|
||||
case ".json":
|
||||
return "application/json"
|
||||
case ".xml":
|
||||
return "text/xml"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue