2424import collections
2525import fnmatch
2626import json
27+ import logging
2728import pathlib
2829import re
2930import sys
3031import tomllib
3132import typing as typ
3233
33- from .rules import DEFAULT_RULES , Rule
34+ from .config import Config , ConfigError , read_config , select_rules
3435from .scanner import Finding , scan_file
3536
3637if typ .TYPE_CHECKING :
3738 import collections .abc as cabc
3839
3940_CONFIG_NAMES : typ .Final = ("ambrleaks.toml" , ".ambrleaks.toml" )
4041
41-
42- class ConfigError (ValueError ):
43- """Raised when a configuration file is structurally invalid."""
44-
45-
46- class Config (typ .NamedTuple ):
47- """Loaded configuration for a scan run.
48-
49- Examples
50- --------
51- ``default_config()`` gives the defaults: shipped rule states and
52- empty allowlists.
53- """
54-
55- rule_states : tuple [tuple [str , bool ], ...]
56- allow_values : tuple [str , ...]
57- allow_tests : tuple [str , ...]
58- allow_paths : tuple [str , ...]
59-
60-
61- def default_config () -> Config :
62- """Return the configuration used when no file is present.
63-
64- Examples
65- --------
66- ``default_config().allow_values`` is empty.
67- """
68- return Config (rule_states = (), allow_values = (), allow_tests = (), allow_paths = ())
69-
70-
71- def _validate_rules (rules : object ) -> None :
72- """Ensure ``[rules]`` is a table of tables with boolean ``enabled``."""
73- if not isinstance (rules , dict ):
74- message = "[rules] must be a table"
75- raise ConfigError (message )
76- for rule_id , entry in rules .items ():
77- if not isinstance (entry , dict ):
78- message = f"[rules.{ rule_id } ] must be a table"
79- raise ConfigError (message )
80- enabled = entry .get ("enabled" , True )
81- if not isinstance (enabled , bool ):
82- message = f"[rules.{ rule_id } ] enabled must be a boolean"
83- raise ConfigError (message )
84-
85-
86- def _validate_string_list (value : object , field : str ) -> None :
87- """Ensure *value* is a list containing only strings."""
88- if isinstance (value , list ) and all (isinstance (item , str ) for item in value ):
89- return
90- message = f"[allowlist] { field } must be a list of strings"
91- raise ConfigError (message )
92-
93-
94- def _validate_allowlist (allowlist : object ) -> None :
95- """Ensure ``[allowlist]`` is a table of recognised string lists."""
96- if not isinstance (allowlist , dict ):
97- message = "[allowlist] must be a table"
98- raise ConfigError (message )
99- for field in ("values" , "tests" , "paths" ):
100- _validate_string_list (allowlist .get (field , []), field )
101-
102-
103- def parse_config (text : str ) -> Config :
104- r"""Parse *text* as an ``ambrleaks`` TOML configuration.
105-
106- This is the pure configuration core: it decodes and structurally
107- validates *text* without any filesystem access, so callers can test
108- it against literal TOML. :func:`read_config` is the filesystem
109- boundary that supplies *text* from a file.
110-
111- Raises
112- ------
113- ConfigError
114- If the configuration is structurally invalid.
115- tomllib.TOMLDecodeError
116- If *text* is not valid TOML.
117-
118- Examples
119- --------
120- ``parse_config("[rules.snapshot-phone]\\nenabled = true\\n")``
121- switches the phone rule on.
122- """
123- data = tomllib .loads (text )
124- rules = data .get ("rules" , {})
125- allowlist = data .get ("allowlist" , {})
126- _validate_rules (rules )
127- _validate_allowlist (allowlist )
128- return Config (
129- rule_states = tuple (
130- (rule_id , bool (entry .get ("enabled" , True )))
131- for rule_id , entry in rules .items ()
132- ),
133- allow_values = tuple (allowlist .get ("values" , ())),
134- allow_tests = tuple (allowlist .get ("tests" , ())),
135- allow_paths = tuple (allowlist .get ("paths" , ())),
136- )
137-
138-
139- def read_config (path : pathlib .Path | None ) -> Config :
140- """Read *path* as TOML configuration, or defaults when ``None``.
141-
142- Thin filesystem boundary over :func:`parse_config`: it reads *path*
143- as UTF-8 and delegates decoding and validation. Read and decode
144- failures propagate to the caller; the CLI reports them at its
145- boundary.
146-
147- Raises
148- ------
149- ConfigError
150- If the configuration is structurally invalid.
151- OSError
152- If the file cannot be read.
153- UnicodeDecodeError
154- If the file is not valid UTF-8.
155- tomllib.TOMLDecodeError
156- If the file is not valid TOML.
157-
158- Examples
159- --------
160- Given an ``ambrleaks.toml`` containing ``[rules.snapshot-phone]``
161- with ``enabled = true``, ``read_config(path)`` switches the phone
162- rule on.
163- """
164- if path is None :
165- return default_config ()
166- return parse_config (path .read_text (encoding = "utf-8" ))
167-
168-
169- def select_rules (config : Config ) -> tuple [Rule , ...]:
170- """Return the shipped rules filtered by *config* overrides.
171-
172- Examples
173- --------
174- With no overrides, every rule enabled by default is returned and
175- the opt-in phone rule is not.
176- """
177- states = dict (config .rule_states )
178- return tuple (
179- rule
180- for rule in DEFAULT_RULES
181- if states .get (rule .rule_id , rule .enabled_by_default )
182- )
42+ # Library-style logging: quiet unless the caller (or --verbose) attaches a
43+ # handler, so default CLI output stays limited to findings and errors.
44+ logger = logging .getLogger ("ambrleaks" )
45+ logger .addHandler (logging .NullHandler ())
18346
18447
18548def _is_allowed (finding : Finding , config : Config ) -> bool :
@@ -260,6 +123,12 @@ def _parse_arguments(argv: cabc.Sequence[str] | None) -> argparse.Namespace:
260123 "masked so a scan report does not reproduce the secret"
261124 ),
262125 )
126+ parser .add_argument (
127+ "-v" ,
128+ "--verbose" ,
129+ action = "store_true" ,
130+ help = "log scan, configuration, and baseline boundary details to stderr" ,
131+ )
263132 return parser .parse_args (argv )
264133
265134
@@ -341,25 +210,44 @@ def _masked_value(value: str) -> str:
341210 return f"{ value [0 ]} { '*' * (len (value ) - 2 )} { value [- 1 ]} "
342211
343212
213+ def _enable_verbose_logging () -> None :
214+ """Attach a stderr handler so boundary logs surface under ``--verbose``."""
215+ if any (not isinstance (h , logging .NullHandler ) for h in logger .handlers ):
216+ logger .setLevel (logging .INFO )
217+ return
218+ handler = logging .StreamHandler ()
219+ handler .setFormatter (logging .Formatter ("ambrleaks: %(levelname)s: %(message)s" ))
220+ logger .addHandler (handler )
221+ logger .setLevel (logging .INFO )
222+
223+
344224def _run (arguments : argparse .Namespace ) -> int :
345225 """Execute the scan described by *arguments* and return an exit code.
346226
347227 The CLI resolves its base directory once here (the working directory
348228 at invocation) and injects it into scanning, so the scanner core
349- never reads the ambient ``cwd`` itself.
229+ never reads the ambient ``cwd`` itself. Structured logs mark the
230+ configuration, scan, and baseline boundaries; they are silent unless
231+ ``--verbose`` (or a caller-attached handler) surfaces them.
350232 """
351- config = read_config (arguments .config or _default_config_path ())
233+ config_path = arguments .config or _default_config_path ()
234+ config = read_config (config_path )
235+ logger .info ("loaded configuration from %s" , config_path or "defaults" )
352236 base_dir = pathlib .Path .cwd ()
353237 findings = _collect_findings (arguments , config , base_dir )
238+ logger .info ("scan produced %d finding(s) under %s" , len (findings ), base_dir )
354239 if arguments .write_baseline is not None :
355240 fingerprints = sorted (f .fingerprint () for f in findings )
356241 arguments .write_baseline .write_text (
357242 json .dumps (fingerprints , indent = 2 ) + "\n " , encoding = "utf-8"
358243 )
244+ logger .info ("wrote baseline of %d fingerprint(s)" , len (fingerprints ))
359245 print (f"ambrleaks: baselined { len (fingerprints )} finding(s)" )
360246 return 0
361247 if arguments .baseline is not None :
248+ before = len (findings )
362249 findings = apply_baseline (findings , read_baseline (arguments .baseline ))
250+ logger .info ("baseline suppressed %d finding(s)" , before - len (findings ))
363251 for finding in findings :
364252 value = finding .value if arguments .show_values else _masked_value (finding .value )
365253 print (
@@ -381,6 +269,8 @@ def main(argv: cabc.Sequence[str] | None = None) -> int:
381269 findings remain after allowlists and the baseline.
382270 """
383271 arguments = _parse_arguments (argv )
272+ if arguments .verbose :
273+ _enable_verbose_logging ()
384274 try :
385275 return _run (arguments )
386276 except (
@@ -390,5 +280,6 @@ def main(argv: cabc.Sequence[str] | None = None) -> int:
390280 tomllib .TOMLDecodeError ,
391281 json .JSONDecodeError ,
392282 ) as err :
283+ logger .exception ("scan failed" )
393284 print (f"ambrleaks: error: { err } " , file = sys .stderr )
394285 return 2
0 commit comments