350 lines
8.3 KiB
Go
350 lines
8.3 KiB
Go
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"
|
|
}
|
|
}
|