masaudit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- masaudit/__init__.py +6 -0
- masaudit/__main__.py +7 -0
- masaudit/cli.py +422 -0
- masaudit/config.py +118 -0
- masaudit/detect.py +50 -0
- masaudit/detectors/__init__.py +0 -0
- masaudit/detectors/android.py +887 -0
- masaudit/detectors/ci.py +270 -0
- masaudit/detectors/cross.py +407 -0
- masaudit/detectors/ios.py +658 -0
- masaudit/detectors/kmp.py +129 -0
- masaudit/engine.py +270 -0
- masaudit/fix.py +212 -0
- masaudit/importers.py +300 -0
- masaudit/lexer.py +103 -0
- masaudit/model.py +84 -0
- masaudit/parsers/__init__.py +0 -0
- masaudit/parsers/gradle.py +161 -0
- masaudit/parsers/manifest.py +82 -0
- masaudit/parsers/pbxproj.py +387 -0
- masaudit/parsers/plist.py +172 -0
- masaudit/parsers/workflows.py +434 -0
- masaudit/parsers/xcconfig.py +122 -0
- masaudit/parsers/xmlsafe.py +91 -0
- masaudit/profile.py +87 -0
- masaudit/report/__init__.py +0 -0
- masaudit/report/jsonout.py +145 -0
- masaudit/report/markdown.py +334 -0
- masaudit/report/sarif.py +145 -0
- masaudit/report/terminal.py +118 -0
- masaudit/rules/__init__.py +0 -0
- masaudit/rules/_book_sections.json +199 -0
- masaudit/rules/_maswe.json +314 -0
- masaudit/rules/_meta.json +11 -0
- masaudit/rules/android.json +931 -0
- masaudit/rules/ci.json +166 -0
- masaudit/rules/cross.json +246 -0
- masaudit/rules/ios.json +639 -0
- masaudit/rules/kmp.json +72 -0
- masaudit/safety.py +161 -0
- masaudit/suppress.py +131 -0
- masaudit-0.1.0.dist-info/METADATA +192 -0
- masaudit-0.1.0.dist-info/RECORD +47 -0
- masaudit-0.1.0.dist-info/WHEEL +5 -0
- masaudit-0.1.0.dist-info/entry_points.txt +2 -0
- masaudit-0.1.0.dist-info/licenses/LICENSE +201 -0
- masaudit-0.1.0.dist-info/top_level.txt +1 -0
masaudit/__init__.py
ADDED
masaudit/__main__.py
ADDED
masaudit/cli.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""masaudit command line.
|
|
3
|
+
|
|
4
|
+
Exit codes: 0 ok, 1 open findings at or above --fail-on, 2 tool error.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Dict, List, Optional, Sequence
|
|
16
|
+
|
|
17
|
+
from masaudit import __version__, config, detect, engine, fix, importers, safety, suppress
|
|
18
|
+
from masaudit import profile as profile_mod
|
|
19
|
+
from masaudit.model import SEVERITY_RANK, Finding, Profile, Project, Rule
|
|
20
|
+
|
|
21
|
+
EXIT_OK, EXIT_FINDINGS, EXIT_ERROR = 0, 1, 2
|
|
22
|
+
DEFAULT_REPORT = "SECURITY-AUDIT.md"
|
|
23
|
+
DEFAULT_PATCH = "masaudit-fixes.patch"
|
|
24
|
+
DEFAULT_BASELINE = ".masaudit-baseline.json"
|
|
25
|
+
SPONSOR_LINE = "masaudit is free. If it helped, you can sponsor it: https://github.com/sponsors/hossam9k"
|
|
26
|
+
ASSUMED_LINE = "Assumed general app (MAS-L1). Set --app-type for accurate results."
|
|
27
|
+
|
|
28
|
+
CLUE_SUFFIXES = (".gradle", ".kts", ".kt", ".java", ".swift", ".m", ".mm", ".dart", ".js", ".ts",
|
|
29
|
+
".tsx", ".toml", ".plist", ".entitlements")
|
|
30
|
+
CLUE_NAMES = {"Podfile", "Package.swift", "pubspec.yaml", "package.json", "Cartfile"}
|
|
31
|
+
MAX_CLUE_FILES = 3000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ToolError(Exception):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ScanResult:
|
|
40
|
+
root: str
|
|
41
|
+
project: Project
|
|
42
|
+
profile: Profile
|
|
43
|
+
findings: List[Finding]
|
|
44
|
+
rules: List[Rule]
|
|
45
|
+
rules_version: str
|
|
46
|
+
notes: List[str] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------- arguments
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
EPILOG = f"""\
|
|
53
|
+
examples:
|
|
54
|
+
masaudit scan the current directory, write SECURITY-AUDIT.md
|
|
55
|
+
masaudit app --app-type banking scan ./app as a banking app
|
|
56
|
+
masaudit --format sarif --output masaudit.sarif --fail-on high
|
|
57
|
+
masaudit --fix-safe preview safe fixes as a diff (changes nothing)
|
|
58
|
+
masaudit --explain AND-001 read about one rule (offline)
|
|
59
|
+
|
|
60
|
+
app types: {", ".join(profile_mod.APP_TYPES)}
|
|
61
|
+
|
|
62
|
+
{SPONSOR_LINE}"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
66
|
+
p = argparse.ArgumentParser(
|
|
67
|
+
prog="masaudit",
|
|
68
|
+
description="Mobile security scanner that scales its advice to your app's risk profile.",
|
|
69
|
+
epilog=EPILOG,
|
|
70
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
71
|
+
)
|
|
72
|
+
p.add_argument("path", nargs="?", default=".", metavar="PATH", help="project to scan (default: .)")
|
|
73
|
+
p.add_argument("--app-type", metavar="TYPE", help="what kind of app this is (see 'app types' below)")
|
|
74
|
+
p.add_argument("--format", choices=["md", "json", "sarif", "text"],
|
|
75
|
+
help="output format (default: summary on screen + SECURITY-AUDIT.md)")
|
|
76
|
+
p.add_argument("--output", metavar="FILE", help="write the report here instead of the default")
|
|
77
|
+
p.add_argument("--fail-on", choices=list(config.FAIL_ON),
|
|
78
|
+
help="exit 1 if an open finding to fix is at or above this severity")
|
|
79
|
+
p.add_argument("--baseline", metavar="FILE", help="ignore findings listed in this baseline file")
|
|
80
|
+
p.add_argument("--update-baseline", action="store_true",
|
|
81
|
+
help=f"write current findings to the baseline (default file: {DEFAULT_BASELINE})")
|
|
82
|
+
p.add_argument("--fix-safe", action="store_true",
|
|
83
|
+
help="print a diff of vetted safe fixes (changes nothing)")
|
|
84
|
+
p.add_argument("--write-patch", nargs="?", const=DEFAULT_PATCH, metavar="FILE",
|
|
85
|
+
help=f"with --fix-safe: also save the diff (default: {DEFAULT_PATCH})")
|
|
86
|
+
p.add_argument("--yes", action="store_true",
|
|
87
|
+
help="with --fix-safe: apply the fixes in place (needs a clean git tree)")
|
|
88
|
+
p.add_argument("--import-sarif", nargs="+", metavar="FILE", default=[],
|
|
89
|
+
help="add results from Android Lint, mobsfscan, Semgrep, or zizmor SARIF files")
|
|
90
|
+
p.add_argument("--sarif-include-not-needed", action="store_true",
|
|
91
|
+
help="include 'not needed for your app' findings in SARIF as notes")
|
|
92
|
+
p.add_argument("--explain", metavar="RULE-ID", help="print what a rule checks and how to fix it")
|
|
93
|
+
p.add_argument("--list-rules", action="store_true", help="list every rule")
|
|
94
|
+
p.add_argument("--no-color", action="store_true", help="no colors in terminal output")
|
|
95
|
+
p.add_argument("--version", action="version",
|
|
96
|
+
version=f"masaudit {__version__} (rules {_safe_rules_version()})")
|
|
97
|
+
return p
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _safe_rules_version() -> str:
|
|
101
|
+
try:
|
|
102
|
+
return engine.rules_version()
|
|
103
|
+
except Exception:
|
|
104
|
+
return "unknown"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------- profile
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _is_interactive() -> bool:
|
|
111
|
+
if os.environ.get("CI"):
|
|
112
|
+
return False
|
|
113
|
+
try:
|
|
114
|
+
return sys.stdin.isatty()
|
|
115
|
+
except (AttributeError, ValueError):
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _ask(prompt: str) -> Optional[str]:
|
|
120
|
+
sys.stderr.write(prompt)
|
|
121
|
+
sys.stderr.flush()
|
|
122
|
+
try:
|
|
123
|
+
return input()
|
|
124
|
+
except EOFError:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def prompt_app_type(root: str, suggestions: List[str]) -> Optional[str]:
|
|
129
|
+
"""Ask the one question. Returns an app type, or None if the user gave no usable answer."""
|
|
130
|
+
types = list(profile_mod.APP_TYPES)
|
|
131
|
+
lines = ["", "What kind of app is this?"]
|
|
132
|
+
for i, key in enumerate(types, 1):
|
|
133
|
+
lines.append(f" {i:>2}. {key:<16} {profile_mod.APP_TYPES[key][1]}")
|
|
134
|
+
if suggestions:
|
|
135
|
+
lines.append("")
|
|
136
|
+
lines.append("Code clues (suggestions only):")
|
|
137
|
+
lines += [f" - {s}" for s in suggestions]
|
|
138
|
+
sys.stderr.write(asciify("\n".join(lines)) + "\n")
|
|
139
|
+
default_num = types.index("general") + 1
|
|
140
|
+
for _ in range(3):
|
|
141
|
+
answer = _ask(f"Enter a number or name [{default_num} = general]: ")
|
|
142
|
+
if answer is None:
|
|
143
|
+
return None
|
|
144
|
+
answer = answer.strip()
|
|
145
|
+
if not answer:
|
|
146
|
+
return "general"
|
|
147
|
+
if answer.isdigit() and 1 <= int(answer) <= len(types):
|
|
148
|
+
return types[int(answer) - 1]
|
|
149
|
+
try:
|
|
150
|
+
return profile_mod.normalize(answer)
|
|
151
|
+
except ValueError:
|
|
152
|
+
sys.stderr.write("Not one of the listed types. Try again.\n")
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _offer_save(root: str, app_type: str) -> None:
|
|
157
|
+
answer = _ask(f"Save '{app_type}' to {config.FILE_NAME} so you aren't asked again? [Y/n]: ")
|
|
158
|
+
if answer is None or answer.strip().lower() not in ("", "y", "yes"):
|
|
159
|
+
return
|
|
160
|
+
try:
|
|
161
|
+
path = config.save_app_type(root, app_type)
|
|
162
|
+
sys.stderr.write(f"Saved {path}\n")
|
|
163
|
+
except OSError as exc:
|
|
164
|
+
sys.stderr.write(f"Couldn't save {config.FILE_NAME}: {exc}\n")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def resolve_profile(cli_type: Optional[str], cfg: Dict[str, object], root: str,
|
|
168
|
+
suggestions: List[str], allow_prompt: bool = True) -> Profile:
|
|
169
|
+
if cli_type:
|
|
170
|
+
try:
|
|
171
|
+
prof = profile_mod.make(cli_type, "flag")
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
raise ToolError(str(exc))
|
|
174
|
+
elif cfg.get("app_type"):
|
|
175
|
+
prof = profile_mod.make(str(cfg["app_type"]), "config")
|
|
176
|
+
else:
|
|
177
|
+
chosen = prompt_app_type(root, suggestions) if allow_prompt and _is_interactive() else None
|
|
178
|
+
if chosen:
|
|
179
|
+
prof = profile_mod.make(chosen, "prompt")
|
|
180
|
+
_offer_save(root, chosen)
|
|
181
|
+
else:
|
|
182
|
+
prof = profile_mod.make("general", "default")
|
|
183
|
+
prof.suggestions = list(suggestions)
|
|
184
|
+
return prof
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------- scanning
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def collect_clues(root: str, files: List[str]) -> List[str]:
|
|
191
|
+
texts: Dict[str, str] = {}
|
|
192
|
+
for rel in files:
|
|
193
|
+
base = rel.rsplit("/", 1)[-1]
|
|
194
|
+
if not (rel.endswith(CLUE_SUFFIXES) or base in CLUE_NAMES):
|
|
195
|
+
continue
|
|
196
|
+
text = safety.read_text(root, rel)
|
|
197
|
+
if text is not None:
|
|
198
|
+
texts[rel] = text
|
|
199
|
+
if len(texts) >= MAX_CLUE_FILES:
|
|
200
|
+
break
|
|
201
|
+
return profile_mod.infer_clues(texts)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def scan(root: str, cli_type: Optional[str] = None, cfg: Optional[Dict[str, object]] = None,
|
|
205
|
+
rules: Optional[List[Rule]] = None, import_sarif: Sequence[str] = (),
|
|
206
|
+
baseline: Optional[str] = None, update_baseline: bool = False,
|
|
207
|
+
allow_prompt: bool = True) -> ScanResult:
|
|
208
|
+
root = os.path.abspath(root)
|
|
209
|
+
if not os.path.isdir(root):
|
|
210
|
+
raise ToolError(f"not a directory: {root}")
|
|
211
|
+
cfg = cfg if cfg is not None else config.load(root)
|
|
212
|
+
files = safety.walk(root)
|
|
213
|
+
excludes = list(cfg.get("exclude") or []) # type: ignore[arg-type]
|
|
214
|
+
if excludes:
|
|
215
|
+
files = [f for f in files if not config.is_excluded(f, excludes)]
|
|
216
|
+
platforms = detect.platforms(files)
|
|
217
|
+
suggestions = collect_clues(root, files)
|
|
218
|
+
prof = resolve_profile(cli_type, cfg, root, suggestions, allow_prompt)
|
|
219
|
+
|
|
220
|
+
project = Project(root=root, platforms=platforms, files=files)
|
|
221
|
+
all_rules = rules if rules is not None else engine.load_rules()
|
|
222
|
+
findings = engine.run(project, all_rules, prof)
|
|
223
|
+
notes: List[str] = []
|
|
224
|
+
for sarif_path in import_sarif:
|
|
225
|
+
try:
|
|
226
|
+
extra, extra_notes = importers.import_sarif(sarif_path, root, all_rules)
|
|
227
|
+
except importers.SarifImportError as exc:
|
|
228
|
+
raise ToolError(str(exc))
|
|
229
|
+
findings += extra
|
|
230
|
+
notes += extra_notes
|
|
231
|
+
findings = _dedupe(findings)
|
|
232
|
+
engine.calibrate(findings, prof)
|
|
233
|
+
notes += suppress.apply_inline(findings, root)
|
|
234
|
+
if baseline or update_baseline:
|
|
235
|
+
path = baseline or os.path.join(root, DEFAULT_BASELINE)
|
|
236
|
+
if update_baseline:
|
|
237
|
+
n = suppress.write_baseline(path, findings)
|
|
238
|
+
notes.append(f"Wrote {n} finding(s) to the baseline {os.path.basename(path)}.")
|
|
239
|
+
try:
|
|
240
|
+
fps = suppress.load_baseline(path)
|
|
241
|
+
except suppress.BaselineError as exc:
|
|
242
|
+
raise ToolError(str(exc))
|
|
243
|
+
suppress.apply_baseline(findings, fps)
|
|
244
|
+
return ScanResult(root=root, project=project, profile=prof, findings=findings, rules=all_rules,
|
|
245
|
+
rules_version=engine.rules_version(), notes=notes)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _dedupe(findings: List[Finding]) -> List[Finding]:
|
|
249
|
+
seen = set()
|
|
250
|
+
out = []
|
|
251
|
+
for f in findings:
|
|
252
|
+
key = (f.rule.id, f.location.path, f.location.line)
|
|
253
|
+
if key in seen:
|
|
254
|
+
continue
|
|
255
|
+
seen.add(key)
|
|
256
|
+
out.append(f)
|
|
257
|
+
return out
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def fails(findings: List[Finding], fail_on: Optional[str]) -> bool:
|
|
261
|
+
if not fail_on:
|
|
262
|
+
return False
|
|
263
|
+
limit = SEVERITY_RANK[fail_on]
|
|
264
|
+
return any(f.status == "open" and f.bucket in ("fix-before-release", "fix-soon")
|
|
265
|
+
and SEVERITY_RANK.get(f.effective_severity, 9) <= limit for f in findings)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------------------------------------------------------------------------- output helpers
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def asciify(s: str) -> str:
|
|
272
|
+
from masaudit.report.terminal import asciify as _a
|
|
273
|
+
|
|
274
|
+
return _a(s)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _write_stdout(text: str) -> None:
|
|
278
|
+
data = text.encode("utf-8")
|
|
279
|
+
buf = getattr(sys.stdout, "buffer", None)
|
|
280
|
+
if buf is not None:
|
|
281
|
+
sys.stdout.flush()
|
|
282
|
+
buf.write(data)
|
|
283
|
+
buf.flush()
|
|
284
|
+
else:
|
|
285
|
+
sys.stdout.write(text)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _write_file(path: str, text: str) -> None:
|
|
289
|
+
try:
|
|
290
|
+
directory = os.path.dirname(os.path.abspath(path))
|
|
291
|
+
os.makedirs(directory, exist_ok=True)
|
|
292
|
+
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
|
293
|
+
fh.write(text)
|
|
294
|
+
except OSError as exc:
|
|
295
|
+
raise ToolError(f"can't write {path}: {exc}")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _find_rule(rules: List[Rule], rule_id: str) -> Optional[Rule]:
|
|
299
|
+
want = rule_id.strip().upper()
|
|
300
|
+
return next((r for r in rules if r.id.upper() == want), None)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------- fix-safe
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _git_clean(root: str) -> Optional[str]:
|
|
307
|
+
"""None if the git tree at root is clean; otherwise the reason it isn't usable."""
|
|
308
|
+
git = shutil.which("git")
|
|
309
|
+
if not git:
|
|
310
|
+
return "git is not installed, so masaudit can't confirm your changes are safe to overwrite."
|
|
311
|
+
try:
|
|
312
|
+
# A hostile repo config must not run a command on `git status`: no fsmonitor, no hooks
|
|
313
|
+
# (post-index-change), and no optional index writes that would trigger them.
|
|
314
|
+
env = {**os.environ, "GIT_OPTIONAL_LOCKS": "0"}
|
|
315
|
+
proc = subprocess.run([git, "-c", "core.fsmonitor=false", "-c", f"core.hooksPath={os.devnull}",
|
|
316
|
+
"status", "--porcelain", "--untracked-files=normal"],
|
|
317
|
+
cwd=root, capture_output=True, text=True, timeout=60, env=env)
|
|
318
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
319
|
+
return f"couldn't run git status: {exc}"
|
|
320
|
+
if proc.returncode != 0:
|
|
321
|
+
return "this folder isn't a git repository, so there is no way to undo the change."
|
|
322
|
+
if proc.stdout.strip():
|
|
323
|
+
return "the git working tree has uncommitted changes. Commit or stash them first."
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def run_fix_safe(result: ScanResult, args) -> int:
|
|
328
|
+
plan = fix.plan(result.root, result.findings)
|
|
329
|
+
diff = fix.unified_diff(plan.fixes)
|
|
330
|
+
if diff:
|
|
331
|
+
_write_stdout(diff)
|
|
332
|
+
else:
|
|
333
|
+
sys.stderr.write("No safe fixes to apply.\n")
|
|
334
|
+
for s in plan.suggestions:
|
|
335
|
+
sys.stderr.write(asciify(f"Suggestion (not changed): {s}") + "\n")
|
|
336
|
+
for s in plan.skipped:
|
|
337
|
+
sys.stderr.write(asciify(f"Skipped: {s}") + "\n")
|
|
338
|
+
if args.write_patch and diff:
|
|
339
|
+
_write_file(args.write_patch, diff)
|
|
340
|
+
sys.stderr.write(f"Wrote {args.write_patch}. Apply it with: git apply {args.write_patch}\n")
|
|
341
|
+
if args.yes and plan.fixes:
|
|
342
|
+
reason = _git_clean(result.root)
|
|
343
|
+
if reason:
|
|
344
|
+
sys.stderr.write(f"masaudit: not applying fixes: {reason}\n")
|
|
345
|
+
return EXIT_ERROR
|
|
346
|
+
written = fix.apply(result.root, plan.fixes)
|
|
347
|
+
sys.stderr.write(f"Applied {len(written)} fix(es): {', '.join(written)}\n")
|
|
348
|
+
return EXIT_OK
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ---------------------------------------------------------------------------- main
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
355
|
+
parser = build_parser()
|
|
356
|
+
args = parser.parse_args(argv)
|
|
357
|
+
try:
|
|
358
|
+
return _main(args)
|
|
359
|
+
except (ToolError, config.ConfigError) as exc:
|
|
360
|
+
sys.stderr.write(asciify(f"masaudit: error: {exc}") + "\n")
|
|
361
|
+
return EXIT_ERROR
|
|
362
|
+
except KeyboardInterrupt:
|
|
363
|
+
sys.stderr.write("masaudit: interrupted\n")
|
|
364
|
+
return EXIT_ERROR
|
|
365
|
+
except Exception as exc: # a bug or a broken rule: never a silent pass
|
|
366
|
+
sys.stderr.write(asciify(f"masaudit: internal error: {exc.__class__.__name__}: {exc}") + "\n")
|
|
367
|
+
return EXIT_ERROR
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _main(args) -> int:
|
|
371
|
+
from masaudit.report import jsonout, markdown, sarif, terminal
|
|
372
|
+
|
|
373
|
+
if args.explain or args.list_rules:
|
|
374
|
+
rules = engine.load_rules()
|
|
375
|
+
if args.list_rules:
|
|
376
|
+
sys.stdout.write(terminal.list_rules(rules))
|
|
377
|
+
if args.explain:
|
|
378
|
+
rule = _find_rule(rules, args.explain)
|
|
379
|
+
if rule is None:
|
|
380
|
+
raise ToolError(f"no rule with id {args.explain} (try --list-rules)")
|
|
381
|
+
sys.stdout.write(terminal.explain(rule))
|
|
382
|
+
return EXIT_OK
|
|
383
|
+
if (args.yes or args.write_patch) and not args.fix_safe:
|
|
384
|
+
raise ToolError("--yes and --write-patch only work together with --fix-safe")
|
|
385
|
+
|
|
386
|
+
root = os.path.abspath(args.path)
|
|
387
|
+
if not os.path.isdir(root):
|
|
388
|
+
raise ToolError(f"not a directory: {args.path}")
|
|
389
|
+
cfg = config.load(root)
|
|
390
|
+
fail_on = config.resolve_fail_on(args.fail_on, cfg)
|
|
391
|
+
result = scan(root, cli_type=args.app_type, cfg=cfg, import_sarif=args.import_sarif,
|
|
392
|
+
baseline=args.baseline, update_baseline=args.update_baseline)
|
|
393
|
+
|
|
394
|
+
if args.fix_safe:
|
|
395
|
+
return run_fix_safe(result, args)
|
|
396
|
+
|
|
397
|
+
color = terminal.use_color(args.no_color)
|
|
398
|
+
if args.format is None:
|
|
399
|
+
out_path = os.path.abspath(args.output) if args.output else os.path.join(root, DEFAULT_REPORT)
|
|
400
|
+
_write_file(out_path, markdown.render(result))
|
|
401
|
+
sys.stdout.write(terminal.render(result, report_path=out_path, color=color))
|
|
402
|
+
else:
|
|
403
|
+
if args.format == "md":
|
|
404
|
+
text = markdown.render(result)
|
|
405
|
+
elif args.format == "json":
|
|
406
|
+
text = jsonout.render(result)
|
|
407
|
+
elif args.format == "sarif":
|
|
408
|
+
text = sarif.render(result, include_not_needed=args.sarif_include_not_needed)
|
|
409
|
+
else:
|
|
410
|
+
text = terminal.render(result, report_path=None, color=color and not args.output)
|
|
411
|
+
if args.output:
|
|
412
|
+
_write_file(args.output, text)
|
|
413
|
+
sys.stderr.write(f"Wrote {args.output}\n")
|
|
414
|
+
else:
|
|
415
|
+
_write_stdout(text)
|
|
416
|
+
if result.profile.source == "default" and args.format != "text":
|
|
417
|
+
sys.stderr.write(ASSUMED_LINE + "\n")
|
|
418
|
+
return EXIT_FINDINGS if fails(result.findings, fail_on) else EXIT_OK
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
if __name__ == "__main__": # pragma: no cover
|
|
422
|
+
sys.exit(main())
|
masaudit/config.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""`.masaudit.ini` at the scan root: per-project defaults.
|
|
3
|
+
|
|
4
|
+
[masaudit]
|
|
5
|
+
app_type = banking
|
|
6
|
+
fail_on = high
|
|
7
|
+
exclude = vendor/*, third_party/**
|
|
8
|
+
|
|
9
|
+
Precedence is CLI > config > prompt > default. Unknown sections or keys are an error, so a
|
|
10
|
+
typo never silently changes what gets checked.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import configparser
|
|
16
|
+
import fnmatch
|
|
17
|
+
import os
|
|
18
|
+
from typing import Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from masaudit import profile
|
|
21
|
+
|
|
22
|
+
FILE_NAME = ".masaudit.ini"
|
|
23
|
+
SECTION = "masaudit"
|
|
24
|
+
KEYS = ("app_type", "fail_on", "exclude")
|
|
25
|
+
FAIL_ON = ("critical", "high", "medium", "low")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConfigError(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def path_for(root: str) -> str:
|
|
33
|
+
return os.path.join(root, FILE_NAME)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load(root: str) -> Dict[str, object]:
|
|
37
|
+
"""Return {"app_type": str|None, "fail_on": str|None, "exclude": [globs]}."""
|
|
38
|
+
out: Dict[str, object] = {"app_type": None, "fail_on": None, "exclude": []}
|
|
39
|
+
path = path_for(root)
|
|
40
|
+
if not os.path.isfile(path):
|
|
41
|
+
return out
|
|
42
|
+
parser = configparser.ConfigParser(interpolation=None)
|
|
43
|
+
try:
|
|
44
|
+
with open(path, encoding="utf-8") as fh:
|
|
45
|
+
parser.read_file(fh, source=path)
|
|
46
|
+
except (configparser.Error, OSError, UnicodeDecodeError) as exc:
|
|
47
|
+
raise ConfigError(f"{FILE_NAME}: can't read it ({exc.__class__.__name__}: {exc})")
|
|
48
|
+
for section in parser.sections():
|
|
49
|
+
if section != SECTION:
|
|
50
|
+
raise ConfigError(f"{FILE_NAME}: unknown section [{section}] (only [{SECTION}] is allowed)")
|
|
51
|
+
if not parser.has_section(SECTION):
|
|
52
|
+
return out
|
|
53
|
+
for key in parser.options(SECTION):
|
|
54
|
+
if key not in KEYS:
|
|
55
|
+
raise ConfigError(f"{FILE_NAME}: unknown key '{key}' (allowed: {', '.join(KEYS)})")
|
|
56
|
+
sec = parser[SECTION]
|
|
57
|
+
if sec.get("app_type", "").strip():
|
|
58
|
+
try:
|
|
59
|
+
out["app_type"] = profile.normalize(sec["app_type"])
|
|
60
|
+
except ValueError as exc:
|
|
61
|
+
raise ConfigError(f"{FILE_NAME}: {exc}")
|
|
62
|
+
if sec.get("fail_on", "").strip():
|
|
63
|
+
value = sec["fail_on"].strip().lower()
|
|
64
|
+
if value not in FAIL_ON:
|
|
65
|
+
raise ConfigError(f"{FILE_NAME}: fail_on must be one of {', '.join(FAIL_ON)}, not '{value}'")
|
|
66
|
+
out["fail_on"] = value
|
|
67
|
+
if sec.get("exclude", "").strip():
|
|
68
|
+
out["exclude"] = split_globs(sec["exclude"])
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def split_globs(value: str) -> List[str]:
|
|
73
|
+
parts = value.replace("\n", ",").split(",")
|
|
74
|
+
return [p.strip().replace("\\", "/") for p in parts if p.strip()]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_excluded(path: str, globs: List[str]) -> bool:
|
|
78
|
+
"""True if a repo-relative path matches a glob, or sits under a directory that does."""
|
|
79
|
+
if not globs:
|
|
80
|
+
return False
|
|
81
|
+
parts = path.split("/")
|
|
82
|
+
prefixes = ["/".join(parts[: i + 1]) for i in range(len(parts))]
|
|
83
|
+
for pattern in globs:
|
|
84
|
+
pat = pattern[2:] if pattern.startswith("./") else pattern
|
|
85
|
+
pat = pat.rstrip("/")
|
|
86
|
+
if pat.endswith("/**"):
|
|
87
|
+
pat = pat[:-3]
|
|
88
|
+
for candidate in prefixes:
|
|
89
|
+
if fnmatch.fnmatchcase(candidate, pat):
|
|
90
|
+
return True
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def save_app_type(root: str, app_type: str) -> str:
|
|
95
|
+
"""Write (or add) app_type to .masaudit.ini, keeping any existing lines. Returns the path."""
|
|
96
|
+
path = path_for(root)
|
|
97
|
+
line = f"app_type = {app_type}\n"
|
|
98
|
+
if not os.path.isfile(path):
|
|
99
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
100
|
+
fh.write(f"[{SECTION}]\n{line}")
|
|
101
|
+
return path
|
|
102
|
+
with open(path, encoding="utf-8") as fh:
|
|
103
|
+
lines = fh.readlines()
|
|
104
|
+
for i, existing in enumerate(lines):
|
|
105
|
+
if existing.strip() == f"[{SECTION}]":
|
|
106
|
+
lines.insert(i + 1, line)
|
|
107
|
+
break
|
|
108
|
+
else:
|
|
109
|
+
if lines and not lines[-1].endswith("\n"):
|
|
110
|
+
lines[-1] += "\n"
|
|
111
|
+
lines += [f"[{SECTION}]\n", line]
|
|
112
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
113
|
+
fh.writelines(lines)
|
|
114
|
+
return path
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve_fail_on(cli_value: Optional[str], cfg: Dict[str, object]) -> Optional[str]:
|
|
118
|
+
return cli_value or cfg.get("fail_on") # type: ignore[return-value]
|
masaudit/detect.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Detect which platforms a project contains, from file names alone."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def platforms(files: List[str]) -> List[str]:
|
|
10
|
+
names = set(files)
|
|
11
|
+
found: List[str] = []
|
|
12
|
+
if any(f.endswith("AndroidManifest.xml") for f in names) or any(
|
|
13
|
+
f.endswith(("build.gradle", "build.gradle.kts")) for f in names
|
|
14
|
+
):
|
|
15
|
+
found.append("android")
|
|
16
|
+
if any(".xcodeproj/" in f or f.endswith(("Info.plist", ".entitlements", "Package.swift", "Podfile"))
|
|
17
|
+
for f in names) or any(f.endswith(".swift") for f in names):
|
|
18
|
+
found.append("ios")
|
|
19
|
+
if any("/commonMain/" in f or f.startswith("commonMain/") for f in names):
|
|
20
|
+
found.append("kmp")
|
|
21
|
+
if "pubspec.yaml" in names or any(f.endswith("/pubspec.yaml") for f in names):
|
|
22
|
+
found.append("flutter")
|
|
23
|
+
if any(f.endswith("package.json") for f in names) and any(
|
|
24
|
+
f.endswith(("index.js", "App.tsx", "App.js")) for f in names
|
|
25
|
+
):
|
|
26
|
+
found.append("react-native")
|
|
27
|
+
if any(f.startswith(".github/workflows/") for f in names):
|
|
28
|
+
found.append("ci")
|
|
29
|
+
return found
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def scope_of(path: str) -> str:
|
|
33
|
+
"""Where in the project a file lives. Used to downgrade hits outside production code."""
|
|
34
|
+
p = "/" + path.lower()
|
|
35
|
+
if "/.github/" in p:
|
|
36
|
+
return "ci"
|
|
37
|
+
if any(s in p for s in ("/src/test/", "/src/androidtest/", "/tests/", "/test/", "uitests/",
|
|
38
|
+
"tests.swift", "test.kt", "test.java", "/commontest/", "/fixtures/")):
|
|
39
|
+
return "test"
|
|
40
|
+
if any(s in p for s in ("/src/debug/", "/src/mock/")):
|
|
41
|
+
return "debug"
|
|
42
|
+
# Sample/demo modules count as debug code, but only as module directories: a package such as
|
|
43
|
+
# src/main/java/com/example/... is production code.
|
|
44
|
+
module = p.split("/src/", 1)[0] if "/src/" in p else p.rsplit("/", 1)[0]
|
|
45
|
+
if any(seg in ("sample", "samples", "example", "examples", "demo", "demos")
|
|
46
|
+
for seg in module.split("/")):
|
|
47
|
+
return "debug"
|
|
48
|
+
if p.endswith((".gradle", ".gradle.kts", "podfile", "package.swift", ".toml")):
|
|
49
|
+
return "build-script"
|
|
50
|
+
return "main"
|
|
File without changes
|