Test yourself
Python for security automation
Final exam · 46 questions · answers explained as you pick
Python on the lab machine
7 questions
01On a fresh Ubuntu 26.04 server with only
python3-venv added, a crontab line reads 0 3 * * * cd /opt/audit && python3 -m pip install -q -r requirements.txt && python3 audit.py. What happens each night?Incorrect —
python3-venv gives each venv its own pip; it adds none to the system python3. With python3-pip installed, PEP 668 would refuse the install anyway.Correct — Ubuntu ships no pip for its own Python. Install once by hand with
/opt/audit/.venv/bin/python -m pip, and put the venv interpreter's absolute path in the crontab.Incorrect — There is no pip to run. Even with
python3-pip, Ubuntu's EXTERNALLY-MANAGED marker makes pip refuse rather than fall back to your home directory.Incorrect — Changing directory activates nothing. cron runs the line with
/bin/sh and the system PATH, so python3 is /usr/bin/python3.02A team installs a PyPI package it has not reviewed into a dedicated venv, reasoning that the venv keeps it away from the rest of the host. What does the venv really separate?
Incorrect — A venv changes where imports come from and nothing about networking. Code in it can open connections as your user.
Incorrect — Code in a venv runs as your user and reads what you can read,
~/.ssh and tokens in your environment included.Incorrect —
.venv/bin/python3 is a symbolic link to /usr/bin/python3 and runs with your user's rights.Correct — A venv is a separate place for third-party packages, not a sandbox. Untrusted code needs a container, a virtual machine or a separate account.
03A script sets
approved = {"web01", "web02"} and runs for host in open("hosts.txt", encoding="utf-8"): print(host in approved). hosts.txt holds web01 and web02, one per line. It prints False twice. Why?Correct — Iterating over a file yields each line with its
\n, as for line in sys.stdin did in the lesson. print(repr(host)), or !r in an f-string, shows it at once.Incorrect — Sets compare by value (using a hash).
"web01" in approved is true for any equal string, wherever it came from.Incorrect — With
encoding= and no b in the mode, the file yields str lines. The difference is the newline.Incorrect — A set has no order, and
in does not depend on how it was built. The lines themselves differ from the members.04A blocking script reads
limit = os.environ.get("MAX_FAILS", 5) and later tests if count > limit:. Every test passes. In production the unit sets MAX_FAILS=10, and the script crashes at its first comparison. What is the crash, and why there?Incorrect —
get() converts nothing. It returns the string that is set, or the default when the variable is missing. The error comes from the comparison.Incorrect — The variable did reach the process, which is why
get() returned its value instead of the default.Correct — The default is an
int, so tests without the variable passed. Convert once, int(os.environ.get("MAX_FAILS", "5")), so the type no longer depends on the environment.Incorrect — Python 3 does not compare numbers with text at all.
9 > "10" raises TypeError rather than comparing characters.05A job's log ends with
KeyError: 'port', then During handling of the above exception, another exception occurred:, then a traceback that points into an except KeyError: block and ends with NameError: name 'loger' is not defined. What do you fix first?Correct — "During handling" means a new error was raised inside an except block, not a deliberate translation. Fix the handler, and the missing key is reported as designed.
Incorrect — Python adds no errors of its own. The
NameError is a real bug in the handler, and it is why the job crashed instead of reporting the key.Incorrect — A deliberate
raise ... from is announced as "the direct cause of the following exception". This sentence means a second, unplanned failure.Incorrect — A failed import raises
ModuleNotFoundError or ImportError. NameError means a name that was never defined, here a misspelt variable.06
main() is try: return run(), except OSError: return 2, finally: print("done", file=sys.stderr), and the script ends with sys.exit(main()). run() raises ValueError. What does the process do?Incorrect —
finally runs whichever way the try ends, including an exception that no clause catches.Incorrect —
finally handles nothing. The ValueError carries on upwards after the block has run.Incorrect —
except OSError catches OSError and its subclasses. ValueError is not one of them.Correct —
finally runs on the way out, then the uncaught exception reaches the top: a traceback and exit status 1.07
parse_port(text: str) -> int declares its types, yet it still checks 1 <= port <= 65535 itself. Why is that check needed?Incorrect — Annotations convert nothing. The lab passed the float
8443.9, and int() quietly made it 8443.Correct — Hints document intent for readers and for tools such as mypy. Input from outside still needs checks in the code.
Incorrect —
-> int promises nothing at run time; it is documentation. Python integers have no fixed width either.Incorrect — The annotations stay on the function. They are simply not checked when it is called.
7 questions · explanations appear as you answer
Files, commands and text
14 questions
01A report job creates its temporary file with
tempfile.mkstemp() (no dir=, so in /tmp) and then calls os.replace(tmp_name, "/srv/reports/daily.txt"). On this server /tmp is a tmpfs. What happens?Incorrect —
os.replace never falls back to copying. It renames in one step or fails.Incorrect — A rename works only within one filesystem, which is why
report.py creates its temporary file in the target's directory; the lab's /tmp rename failed with Errno 18.Correct — Create the temporary file with
dir=target.parent, so the rename stays on one filesystem and readers see the old report or the new one.Incorrect — A failed rename changes neither file; the old report stays where it was. The move itself is what fails.
02A log from an old appliance holds a few bytes that are not UTF-8. You must count its ERROR lines and keep the matching lines as evidence that shows the odd bytes as they were. Which
open() fits?Correct — The count works, and the byte's value stays visible in the saved lines.
replace would put U+FFFD there and lose the value.Incorrect —
replace puts the replacement character in place of the byte. The value itself is gone.Incorrect — Strict decoding raises
UnicodeDecodeError at the first bad byte and stops the count.Incorrect — Dropping bytes silently removes part of the evidence, and the locale's default can differ between hosts.
03A download handler builds
p = os.path.normpath(os.path.join("/srv/uploads", name)) and serves p if p.startswith("/srv/uploads"). Which request reads a file outside /srv/uploads?Incorrect —
normpath folds the .. parts away and gives /etc/passwd, which fails the prefix test.Incorrect — Joining an absolute name discards the base, as
BASE / "/etc/passwd" did in the lab. The result fails the test.Incorrect — It folds to
/srv/uploads/report.txt, a file inside the base, and serving it is correct.Correct — A prefix test compares characters, not path parts. Resolve the path, which also follows planted symbolic links, then check
is_relative_to(BASE).04A wrapper runs
subprocess.run(["clamscan", "--", path], check=True, timeout=120) inside a try that catches only subprocess.CalledProcessError. On a new host the scanner is not installed. What reaches the caller?Incorrect — There is no shell and no child process, so there is no exit status at all. 127 is a shell's convention.
Correct — A program that cannot start never produces a return code. Catch
FileNotFoundError (or OSError) separately, or check with shutil.which() at start-up.Incorrect —
run() fails at once when the program cannot be started. The timeout only runs while a child exists.Incorrect — No
CompletedProcess exists without a child. The exception comes from trying to start the program.05A script runs
result = subprocess.run(["grep", "-c", "ERROR", "app.log"], timeout=10) and then count = int(result.stdout). The terminal shows 2, then a traceback. Why?Incorrect — Without capturing there are no bytes at all. (With
capture_output=True, int() would in fact accept b'2\n'.)Correct —
run() keeps only the output you ask it to capture. Add capture_output=True, text=True, then convert result.stdout.Incorrect —
grep -c exits 0 when it counted matches, and nothing here involves its error output.Incorrect — A timeout limits how long
run() waits and discards nothing. There was no pipe to read in the first place.06A search tool runs
subprocess.run(["grep", "-c", pattern, "--", logfile], ...), where pattern comes from a web form. Which value does grep read as an option rather than as a pattern?Incorrect — grep never runs a shell. The list form passes
a;id to it as one plain argument.Incorrect — Nothing expands
$( ) without a shell. grep receives those five characters as they are.Correct —
-- protects what follows it, not what comes before. Pass the pattern as -e pattern and keep operands after --.Incorrect — A list item is exactly one argument, spaces included. Only a shell would split it.
07Before starting a third-party scanner, a wrapper does
env = dict(os.environ); env.pop("API_TOKEN") and passes env=env. A reviewer still objects. What is the concern?Correct — Deleting the names you know misses the ones you do not. Give the child what it needs, such as
PATH and LANG, and no more.Incorrect —
dict(os.environ) is a separate dictionary. Popping from it leaves os.environ unchanged.Incorrect —
env= replaces the environment completely. The lab's child with a minimal env= found no API_TOKEN.Incorrect — The concern is what leaks to the child, not whether it works. The child still reads its files and the variables you pass.
08A form accepts host names with
if re.search(r"[a-z0-9-]+", name, re.ASCII): allowed(name). What does this check let through?Incorrect —
+ repeats as far as it can but does not require the rest of the string to fit. Nothing anchors the end.Incorrect —
match() anchors at the start. search() scans for the first place where the pattern fits.Incorrect — The pattern is case-sensitive anyway, and
WEB01 passes because its digits match.Correct —
search() finds web01 and stops looking. A validator needs re.fullmatch(), and a real parser such as ipaddress where one exists.09Which of these patterns can keep Python's
re busy for hours on a 40-character run of letters followed by !?Incorrect — A single repetition of one class has one way to split the text, so a near miss fails in linear time.
Incorrect — Each repetition of the group must start with a dot, which leaves one way to split the text. This is the de-nested fix.
Correct — The letters can be split between the inner and outer repetition in exponentially many ways. De-nest it, or make it atomic or possessive.
Incorrect — An atomic group does the opposite: once it has matched, the engine never goes back inside it, so a near miss fails fast.
10What does
yaml.safe_load("window: 22:30\nqueue: 010\nversion: 1.10") return with PyYAML 6.0.3?Correct — YAML 1.1 reads
22:30 as base 60, 010 as octal and 1.10 as a float. Quote text values and check their types after loading.Incorrect — That is what quoting produces. Unquoted,
safe_load still applies YAML 1.1 type guessing.Incorrect — Base-60 values are converted too (1350), and
010 is octal, so it becomes 8, not 10.Incorrect —
safe_load raises nothing here. It guesses types silently, which is why the lesson checks them afterwards.11A pull request changes a rules file so that one value reads
!!python/object/apply:os.system ["curl ... | sh"]. The loader calls yaml.safe_load(). What happens when the file is loaded?Incorrect — That is what an unsafe loader does.
safe_load builds plain values and nothing else.Correct —
safe_load refuses tags it cannot build as plain values; the lab's os.getcwd tag failed with its line and column. Catch yaml.YAMLError and report it.Incorrect —
safe_load does not drop tags quietly. It refuses them with an exception.Incorrect — PyYAML 6 makes you name a loader for
load(), and safe_load stays limited to plain types.12To stop formula injection, a teammate applies
neutralise() from to_csv.py to every CSV export, including the one a ticket importer reads by program. What goes wrong?Incorrect — An apostrophe is ordinary text inside a field. The file is still valid CSV.
Incorrect — The lab's read-back with
DictReader returned the value with its apostrophe.Incorrect —
neutralise() leaves values that are not strings alone (the isinstance check), so numbers pass unchanged.Correct — The prefix changes the data. Neutralise the copy meant for spreadsheets and give programs the JSON unchanged.
13A failed-login counter opens every
auth.log* with open(p, encoding="utf-8", errors="replace"). It runs without an error but reports no failures from auth.log.2.gz or auth.log.3.gz. Why?Incorrect — The rotations hold older lines of the same kind. The parser never saw them as text.
Incorrect — Replacement keeps the line and swaps bytes. The problem is that these are not log lines at all.
Correct — Without
errors="replace" the first read fails on byte 0x8b. Choose gzip.open(p, "rt", ...) by file type before deciding how to handle bad bytes.Incorrect —
open() knows nothing about gzip. It returns the compressed bytes, decoded as if they were UTF-8.14
read_lines(p) is with open(p, encoding="utf-8") as f: return (line.rstrip("\n") for line in f). A caller then loops over read_lines("auth.log"). What is the result?Correct — The generator expression runs when it is iterated, after
return has left the block. Put yield inside the with block, as logread.py does.Incorrect — A generator expression is lazy.
return hands back a generator that has not started.Incorrect — A reference does not reopen a file. The
with block closed it when the function returned.Incorrect — An exhausted generator is silent, but this one never started. Its first read hits a closed file.
14 questions · explanations appear as you answer
Talking to services
13 questions
01A helper reads
for attempt in range(5):, then try: return get_json(url, timeout=2) and except TemporaryFeedError: time.sleep(2 ** attempt). The feed is down all night. What does the caller get?Incorrect — Nothing re-raises it. Each handler sleeps and the loop moves on, so every error is dropped.
Incorrect —
retry() raises that explicitly. This loop has no raise after its last attempt.Incorrect —
range(5) yields five values and ends, so the loop finishes after the fifth failure.Correct — Five failures, five sleeps (1+2+4+8+16 s, the last one useless), then an implicit
return None that a caller may read as "no verdict".02A job opens a ticket with a POST. The ticket API timed out after 10 seconds, the job's generic retry sent the POST twice more, and the next morning there are three identical tickets. What is the lesson?
Incorrect — A longer timeout lowers the chance but keeps the problem: a retried write duplicates whenever the first attempt worked.
Correct — A timeout means no answer arrived, not that nothing happened. Reads can be repeated safely; a write needs a key the server uses to recognise a repeat.
Incorrect — The server never answered in time. The duplicates come from the client repeating a request that had already succeeded.
Incorrect — Jitter spreads retries out in time. It does nothing to stop a repeated write from creating another record.
03A new script calls
log.info("scanning %s", host) for progress and log.warning(...) for problems, but never configures logging. At run time the progress lines are missing. Why?Incorrect — Unconfigured logging shows nothing at INFO. Its fallback handler writes to stderr, and for WARNING and above.
Incorrect —
%s with arguments is the recommended style. The text is built when a record is emitted.Correct — Call
logging.basicConfig() once in the program with the level and format you want. Library modules just call getLogger(__name__).Incorrect — Records travel up to the root logger. The problem is the default level, not a missing handler on this logger.
04In the HTTP lesson, plain
urlopen(url, timeout=2) read the 46-byte answer that /v1/drip sends one byte every quarter second, and took over eleven seconds. What puts a limit on a call like that?Correct — Each read got a byte within 2 s, so the per-step timeout never fired.
get_json() checks its budget after every chunk; for a hard limit on a whole program, run it under timeout from the Bash course.Incorrect —
retry() checks its deadline between attempts. This was one attempt that never failed, so the deadline was never looked at.Incorrect —
urlopen() has no total limit. The lab's call ran for 11.5 s with a 2-second timeout, and a longer body would have taken longer still.Incorrect — A retry follows a failure, and this call never failed; it was slow. A repeated request would drip in the same way.
05The session from
session.py mounts Retry(total=3, status_forcelist=[429, 500, 502, 503, 504], allowed_methods={"GET"}). It sends a POST that creates a ticket, and the API answers 503. What does the calling code see?Incorrect —
allowed_methods limits which methods are retried at all, and POST is not in it.Incorrect — The server did answer. A response with a status is not a connection failure.
Correct —
allowed_methods={"GET"} keeps the adapter from repeating a write, so the status reaches your code after one attempt.Incorrect — An adapter never changes the method. The POST was sent once and its answer came back.
06Two clients get
429 with Retry-After: 3600 from the same API. One reads pages with pages.py (max_wait=10); the other uses the requests session with Retry(..., retry_after_max=10). What does each do?Incorrect — Both clients refuse or cut the wait. Neither sleeps for an hour.
Correct — The lab showed both on
/v1/busy: 0.04 s with pages.py, and about 30 s of capped waits with requests.Incorrect — That is the other way round.
get_page() refuses a wait above max_wait, while Retry cuts the wait to 10 s and still retries.Incorrect —
pages.py refuses; urllib3 caps the wait and retries. Neither treats it as permanent: exit 3 means "try later".07
fetch_all() runs with max_pages=20 against an API whose pages 1, 2, 3, 4 and so on return next_cursor values p2, p3, p2, p3 and so on. How does the call end?Incorrect — The seen-cursor check fires first. The page cap is the backstop for cursors that never repeat.
Incorrect —
fetch_all() has no deadline. Its two guards are the cap and the set of cursors seen.Incorrect — A new cursor is expected on every page. A cursor seen before is what means a loop.
Correct — Pages 1 and 2 add
p2 and p3 to seen; page 3 returns p2 again, and fetch_all() raises ApiError, exit status 1.08A service unit passes its token as
Environment=API_TOKEN=..., on the grounds that /proc/PID/environ has mode 0400 and other users cannot read it. What is wrong with that reasoning?Correct — The service manager answers such queries over D-Bus. Use
LoadCredential=, which the systemd.exec manual recommends for secrets.Incorrect — The journal was not where it leaked.
systemctl show handed it to another account directly.Incorrect — The file is mode 0400 and another account gets "Permission denied". The leak was elsewhere.
Incorrect — An unprivileged second account can still print the
Environment= line.09A tool starts with
token = os.environ.pop("API_TOKEN", ""). Who can still read the token value afterwards?Incorrect — The kernel's copy keeps the environment the process started with, as the lab checked.
Correct —
pop() protects the child processes, not your own process from your own user.Incorrect — Children get the current
os.environ, and the lab's child started after pop() did not see it.Incorrect — Nothing moves to the arguments.
/proc/PID/environ stays mode 0400, and other users get "Permission denied".10A redaction filter is attached with
logging.getLogger("app").addFilter(RedactSecrets([token])). Module app.db logs log.error("connect failed: %s", dsn), and the DSN contains the token. What is written?Incorrect — Logger filters are not inherited. The lab's filter on
app missed the record from app.db.Incorrect — A logger's filter never sees records from other loggers, so it can neither drop nor change them.
Correct — Attach the filter to the handler, which sees the records of every logger it writes for.
Incorrect — The filter only replaces text and raises nothing. The record simply never passes through it.
11A quick verifier reads
SHA256SUMS and returns 0 if all(check(line) for line in lines) else 1. A failed download leaves SHA256SUMS empty. What does the verifier report?Incorrect —
all() of an empty iterable is True: there is no element that fails.Incorrect —
all() accepts any iterable, empty ones included.Incorrect — The generator yields nothing, so
check() never runs.Correct — That is why
verify_sums.py raises "no checksums at all" and exits 2 for an empty list.12A job downloads
tool-2.0.tar.gz and its SHA256SUMS from the same mirror, and sha256sum -c SHA256SUMS prints OK. What has been proven?Correct — Fetch the list over a trusted channel and check its signature. A checksum from the same source proves the bytes match, not where they came from.
Incorrect — No collision is needed: an attacker who controls the mirror writes a new list that matches.
Incorrect — The checksum says the file matches the list. Whether the release itself is benign is another question.
Incorrect — Constant-time comparison matters for secrets such as HMACs. A public checksum can be compared any way you like.
13Before sharing firewall logs with a vendor, a pipeline replaces each client IPv4 address with its SHA-256 hex digest and calls the result anonymous. Is it?
Incorrect — It cannot be run backwards, but it can be guessed: hash each candidate address and compare.
Correct — The lab found
203.0.113.77 after 78 guesses in one /24, and all of IPv4 takes under half an hour on one core.Incorrect — A salt stops precomputed tables and identical digests. Each digest can still be guessed one candidate at a time.
Incorrect — MD5 is weaker, not stronger. The problem is the small set of possible inputs, not the hash function.
13 questions · explanations appear as you answer
Shipping a tool
12 questions
01A cron line runs
curl -fsS "$FEED" | hashcheck scan --iocs - /srv/uploads/*. One night the feed server answers 200 with an empty body. What does hashcheck do, and why is that right?Incorrect — Exit 0 would claim the files are clean when they were checked against nothing.
Incorrect — An empty list matches nothing, and the tool refuses it before hashing a single file.
Correct —
load_iocs() raises IocListError for a list without indicators, and status 2 says a person must look at the feed.Incorrect — An empty body followed by end of file ends the input at once. The tool then refuses the empty list.
02A wrapper runs
hashcheck scan --format json --iocs iocs.txt /srv/uploads/* 2>&1 | jq -c ., and most nights it works. On some nights jq stops with a parse error. What happened on those nights?Correct —
hashcheck writes data to stdout and problems to stderr through logging. Drop 2>&1 so jq reads the NDJSON alone, and act on exit status 3.Incorrect —
hashcheck prints one JSON object per match (NDJSON). There is no array to cut off.Incorrect — The lines come from
json.dumps(), which escapes whatever a path contains.Incorrect — A full pipe makes the writer wait until jq reads. It never drops or corrupts data.
03Why does
hashcheck turn prefixes off with allow_abbrev=False, when --dry works as --dry-run today because the prefix is unique?Incorrect — Parsing speed is not the concern. The risk is what a prefix means after the tool changes.
Incorrect — There is no such warning. By default, prefixes are accepted silently.
Incorrect — argparse accepts prefixes wherever it runs; the lab's
move --dry was accepted.Correct — The job then fails, or silently runs the new option, which wins over a prefix. Turning prefixes off on each parser keeps scheduled command lines exact.
04After a refactor,
retry.py says from time import sleep and calls sleep(delay). The existing test still patches with monkeypatch.setattr(time, "sleep", sleeps.append) and asserts len(sleeps) == 3. What is the result?Incorrect —
setattr rebinds one attribute of one module. retry.py now holds its own reference.Correct —
retry.py now looks up its own name sleep, bound at import time to the real function. Patch retry.sleep, or keep import time in the module.Incorrect —
from time import sleep is valid. The problem shows up when the test runs.Incorrect — monkeypatch does not track who uses an attribute, so nothing warns.
05
test_usage_error_exits_2 wraps main() in pytest.raises(SystemExit), while the other exit-status tests compare the value main() returns. Why the difference?Correct — The test catches the exception and reads
exc.value.code. The statuses main() computes itself come back as return values.Incorrect — pytest does not convert return values. The exception comes from argparse.
Incorrect —
main() behaves the same in both cases. argparse raises SystemExit in both.Incorrect —
capsys reads stderr either way. The reason is how argparse ends the program.06A team's tests replace
get_json with a fake that returns canned data, and the suite passes. In production each 404 is retried as "cannot reach", because someone swapped the HTTPError and URLError handlers inside get_json(). Why did no test fail?Incorrect — The fake raised what each test told it to, so the tests checked the code around
get_json(), not the code inside it.Incorrect — monkeypatch undoes its changes after each test, not during it.
Correct — That is what the real
http.server on port 0 in test_feed.py is for: the request code runs unchanged and each status's class is checked.Incorrect — A local
http.server returns any status you like, as test_feed.py does for /missing.07
pyproject.toml declares dependencies = ["requests>=2.33,<3"], and requirements.lock pins requests==2.34.2 with its hashes. Tomorrow requests 2.35.0 is released. Which install picks up 2.35.0?Incorrect — The lock names 2.34.2 and its hashes. pip installs exactly that until the lock is regenerated.
Incorrect —
--no-deps installs the tool's wheel by itself. requests comes from the lock.Incorrect — pip-audit installs nothing. It checks the locked versions against published advisories.
Correct — pipx resolves the
>=2.33,<3 range at install time and never reads your lock, so it takes the newest release the range allows.08A build server runs
pip install --extra-index-url https://pypi.corp.example/simple -r requirements.txt, where acme-ioc-utils>=1.2 exists on the private index. Someone uploads acme-ioc-utils 99.0 to public PyPI. What happens on the next build?Incorrect — pip treats both indexes as equal and picks the highest version it finds.
Correct — That is dependency confusion. Use one index (a private mirror that proxies PyPI) with
--index-url, and hashes in a lock so a swapped file fails the install.Incorrect — pip reports no conflict. It merges the candidates from both indexes.
Incorrect — PyPI cannot know what your private index holds.
09The server install runs
pip install --require-hashes -r requirements.lock, then pip install --no-deps dist/hashcheck-0.1.0-py3-none-any.whl. What does --no-deps contribute?Correct — Without it pip would resolve the wheel's
Requires-Dist range and could fetch versions that are not in the lock.Incorrect — The wheel is not in the lock.
--no-deps only stops dependency resolution.Incorrect —
pip install never removes unrelated packages. --no-deps skips resolving dependencies and nothing else.Incorrect — That is the opposite: with
--no-deps pip ignores Requires-Dist entirely.10
ioc-sweep's config.py checks each value with type(value) is not kind rather than isinstance(value, kind). What does that prevent?Incorrect —
check() converts an int to a float for float settings. That is a different rule.Incorrect —
isinstance("3", int) is False as well, so strings are refused either way.Correct — With
isinstance() the lab's test failed with DID NOT RAISE, and a sweep would silently try each request once.Incorrect — Paths take their own branch before the type comparison.
11A nightly
ioc-sweep run exits 3. Its stdout holds two findings, and summary.csv marks three other files incomplete with the detail lookup failed. What should the on-call engineer conclude?Incorrect — Findings from batches that answered are real. The partial status is about the files that were not fully checked.
Incorrect — A refused token is exit 2 and ends the run before any findings. This run had lookups that worked.
Incorrect — A failed lookup is unknown, not a non-match. Those files are not proven clean.
Correct — Exit 3 means partial and wins over 1: the findings are printed, but the run did not prove the rest clean. One batch got 5xx or 429 until it gave up.
12
sweep.toml sets batch_size = 3 and max_retry_after = 0.5. The tree holds six files with distinct hashes, no intel matches and no scanner hits. The mock API in throttle mode answers its first request with 429 and Retry-After: 1. What exit status does ioc-sweep return?Incorrect —
ApiUnavailable needs each lookup to fail, and the second batch answered.Incorrect — A
Retry-After above max_retry_after ends the batch instead of waiting.Correct — The second batch answers, so this is not unavailability (4). Three files carry "lookup failed", which makes the run partial.
Incorrect — Exit 2 is for setup errors: 401 and 403 (
AuthError), a 404 on the API path or a failed certificate check. A 429 is treated as unavailability.12 questions · explanations appear as you answer