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.
Files changed (47) hide show
  1. masaudit/__init__.py +6 -0
  2. masaudit/__main__.py +7 -0
  3. masaudit/cli.py +422 -0
  4. masaudit/config.py +118 -0
  5. masaudit/detect.py +50 -0
  6. masaudit/detectors/__init__.py +0 -0
  7. masaudit/detectors/android.py +887 -0
  8. masaudit/detectors/ci.py +270 -0
  9. masaudit/detectors/cross.py +407 -0
  10. masaudit/detectors/ios.py +658 -0
  11. masaudit/detectors/kmp.py +129 -0
  12. masaudit/engine.py +270 -0
  13. masaudit/fix.py +212 -0
  14. masaudit/importers.py +300 -0
  15. masaudit/lexer.py +103 -0
  16. masaudit/model.py +84 -0
  17. masaudit/parsers/__init__.py +0 -0
  18. masaudit/parsers/gradle.py +161 -0
  19. masaudit/parsers/manifest.py +82 -0
  20. masaudit/parsers/pbxproj.py +387 -0
  21. masaudit/parsers/plist.py +172 -0
  22. masaudit/parsers/workflows.py +434 -0
  23. masaudit/parsers/xcconfig.py +122 -0
  24. masaudit/parsers/xmlsafe.py +91 -0
  25. masaudit/profile.py +87 -0
  26. masaudit/report/__init__.py +0 -0
  27. masaudit/report/jsonout.py +145 -0
  28. masaudit/report/markdown.py +334 -0
  29. masaudit/report/sarif.py +145 -0
  30. masaudit/report/terminal.py +118 -0
  31. masaudit/rules/__init__.py +0 -0
  32. masaudit/rules/_book_sections.json +199 -0
  33. masaudit/rules/_maswe.json +314 -0
  34. masaudit/rules/_meta.json +11 -0
  35. masaudit/rules/android.json +931 -0
  36. masaudit/rules/ci.json +166 -0
  37. masaudit/rules/cross.json +246 -0
  38. masaudit/rules/ios.json +639 -0
  39. masaudit/rules/kmp.json +72 -0
  40. masaudit/safety.py +161 -0
  41. masaudit/suppress.py +131 -0
  42. masaudit-0.1.0.dist-info/METADATA +192 -0
  43. masaudit-0.1.0.dist-info/RECORD +47 -0
  44. masaudit-0.1.0.dist-info/WHEEL +5 -0
  45. masaudit-0.1.0.dist-info/entry_points.txt +2 -0
  46. masaudit-0.1.0.dist-info/licenses/LICENSE +201 -0
  47. masaudit-0.1.0.dist-info/top_level.txt +1 -0
masaudit/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """masaudit: a mobile security scanner that scales its advice to your app's risk."""
3
+
4
+ __version__ = "0.1.0"
5
+ BOOK_URL = "https://github.com/hossam9k/mobile-app-security"
6
+ REPO_URL = "https://github.com/hossam9k/masaudit"
masaudit/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ import sys
3
+
4
+ from masaudit.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
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