Skip to content

feat(dns): add dns command for Active DNS lookups - #90

Open
Blevene (Blevene) wants to merge 25 commits into
feat/collections-crudfrom
feat/fqdn-dns
Open

Blevene (Blevene) wants to merge 25 commits into
feat/collections-crudfrom
feat/fqdn-dns

Conversation

@Blevene

@Blevene Blevene (Blevene) commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds censys dns <name|ip>..., a command for Censys Active DNS lookups over a time window.

  • Name input shows the records the name resolved to (A, AAAA, MX, NS, SOA, TXT).
  • IP input shows the domain names that resolved to the IP.
  • --timeline shows each observed time range instead of one row per record. --domain narrows an IP timeline to one domain name ("when did this domain point at this IP").
  • Several names or IPs can be looked up in one command, up to 100: as positional arguments, comma-separated lists, or from a file (or STDIN) with --input-file. Each is looked up independently — one failing input does not stop the rest.
  • Pasted and defanged input works: https://censys.com/path, censys[.]com, hxxp://8.8.8[.]8/x, hxxps[://]censys[.]com. A port (censys.com:443), bracketed IPv6, a CIDR range (including defanged, such as 8[.]8[.]8[.]8/32 and 8.8.8.8[/]32), an @, an empty label, or a non-positive --duration fails with a clear fix before any API call.
  • Output: short table by default; json, yaml, tree, template, and --streaming (NDJSON) are all supported.

It is a new top-level command rather than a mode of view or history: view is a point-in-time snapshot, and every DNS endpoint is a time window, so one meaning per command.

$ censys dns censys.com

DNS records for censys.com (46)
Active 2026-09-21 19:13 → 2026-09-28 19:13 UTC

Type   Value                        First Seen         Last Seen
A    | 141.193.213.10             | 2026-05-24 22:14 | 2026-09-27 21:25
MX   | 1 aspmx.l.google.com       | 2026-04-30 21:42 | 2026-09-27 21:25
...

After a short-format view of a single host, a stderr tip now points at censys dns <ip> (suppressed by --quiet), so the new command is discoverable from the one people already run.

Flags

Flag Default Function
-s/--start, -e/--end, -d/--duration -d 7d Time window (same flags and resolver as history)
-t/--timeline off One row per observed time range (API "ranges")
--domain — Narrow an IP timeline to one domain name; IP input with --timeline only
-r/--record-type all Filter record types (IP input: A, AAAA)
-i/--input-file — Read inputs from a file (or STDIN with -); overrides positional arguments
-n/--page-size, -p/--max-pages 100, 10 Pagination; -p -1 fetches all pages
--org-id — Standard flag

Requires the Censys Search or Core plan; a 403 names the plan requirement, and (with several inputs) keeps and prints whatever was already fetched before reporting it.

Structure

Same layers as the other commands: internal/command/dns → internal/app/dns → four new GlobalDataClient methods → censys-sdk-go.

Two refactors come first so the new code reuses existing logic instead of copying it:

  • internal/app/tags/paginate.go moves to internal/app/pagination (exported; tags only changes imports).
  • history's time-window resolver moves to internal/pkg/flags.ResolveTimeWindow.

Deviations from existing patterns

Each deviation and its reason is also in the body of the commit that contains it.

  1. New assets.NewDomainName identifier: no FQDN type exists, and the web-property parser accepts host:port without an error.
  2. Short output is the default (history defaults to JSON): the closest precedents (censeye, tags, aggregate) show a table first.
  3. The tags paginator moves to a shared package: reuse without a second copy.
  4. Client methods take record types as []string: the SDK generates four enum types for the same values.
  5. resolveTimeWindow moves from history to internal/pkg/flags: one implementation for both commands. It now also rejects a non-positive --duration up front for both commands, instead of letting the API return a 400.
  6. Input detection does not use AssetClassifier: it has no domain type and reads a bare name as a web property on 443.
  7. The invalid-input error keeps the specific reason (for example "remove the port"): it tells the user how to fix the input.
  8. parsePaginationFlags, validatePaginationParams, optionalInt64, and the fetch-all warning are per-package copies: search and tags each own theirs.
  9. A stderr note ("Showing N of M records. Use --max-pages -1 to fetch all.") in addition to the "(n of M)" title count: a busy IP can have tens of thousands of records (8.8.8.8: about 61,000 in 7 days). Suppressed by --quiet.
  10. dns accepts several inputs, where history takes one: DNS lookups are often run over a list of indicators. Several positional arguments follow tags assign/unassign; --input-file and comma lists in arguments follow view (file lines are not split). The API has no bulk operation, so each input is its own lookup, capped at 100 per command, run one after another; a failed input does not stop the rest, and the command exits 0 if at least one input succeeded, except after a plan error (403), which prints what was already fetched and then exits non-zero.
  11. The four DNS record wrappers (NameRecord, NameRangeRecord, IPRecord, IPRangeRecord) define their own MarshalJSON: the SDK's record types already define MarshalJSON, so an embedding wrapper would inherit it and drop the added input field. Every output format (json, yaml, tree, NDJSON) goes through it.
  12. view gained a stderr hint pointing at censys dns <ip>, which the dns feature otherwise does not touch: it makes the new command discoverable from the command people already run for a host. Short output of exactly one host only; suppressed by --quiet.
  13. refang.RefangURL gained support for the bracketed-scheme defang form ([://], as in hxxps[://]censys[.]com), which the dns feature otherwise does not touch: it was unhandled by any command, and dns showed it worst (a malformed name reaching the API). The fix is in the shared package, so it also benefits view, history, tags, and censeye.
  14. The default dns template prints API strings with triple braces (no HTML escaping), unlike the other shipped templates: TXT, SPF, and DKIM values contain quotes and ampersands that HTML escaping would mangle on a terminal. Every string is stripped of control characters before rendering, so nothing unsafe reaches the terminal.
  15. An explicitly empty --domain is a usage error, where tags treats a blank filter value as absent: --domain only narrows an IP timeline, so an empty value (for example an unset shell variable) must not silently widen the lookup to every domain.

Testing

  • go test ./...: 1858 tests pass.
  • Lint: pinned golangci-lint v1.64.8 passes under the go.mod toolchain.
  • E2E dns fixtures (go test ./cmd/cencli/e2e/..., CENCLI_ENABLE_E2E_TESTS=true, credentials unset): all no-auth cases pass — help golden, invalid port, bracketed IPv6, CIDR (plain, defanged, and a defanged slash), too many inputs, an invalid list member, @ in a name, an empty label, a non-positive duration, an invalid/mixed record type, and both --domain misuse cases.
  • Local live smoke suite (55 cases with real API calls, not committed): 55 passed, 0 failed (1 skipped — no credential on hand that Active DNS denies; covered by the unit tests instead). Covers input parsing (name, defanged, URL, defanged URL, a comma inside a URL path, IPv4, IPv6, an IP inside a URL, the bracketed-scheme defang form), rejections (port, bracketed IPv6, CIDR plain/defanged/defanged slash, too many inputs, email, empty label), bulk lists (name+name, name+IP, IP+name, --input-file, a comma-containing file line kept as one input, combined response metadata for several inputs, JSON with the input field), --timeline (name and IP, plus --domain), no-data output, every flag (record type valid/invalid/mixed, explicit window, zero/negative duration, json/yaml/template output (including a check that each template record prints only its own values), streaming, quiet vs. showing note, fetch-all-pages), --domain misuse, regression checks against history/tags/view, and the new view → dns hint (plain and --quiet).
  • CI on PR feat(dns): add dns command for Active DNS lookups #90 (commit d081959): all checks passing, including lint, tests-and-coverage, e2e-native, and e2e-smoke on macOS/Linux/Windows.
  • Demo gifs (examples/dns/dns.gif, examples/dns/dns-timeline.gif) recorded with vhs via go run ./cmd/examples dns.

Not in this PR

🤖 Generated with Claude Code

@Blevene
Blevene (Blevene) requested a review from a team as a code owner September 29, 2026 13:33
@Blevene

Copy link
Copy Markdown
Collaborator Author

@greptile review

@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium risk] Adds a new DNS lookup command to the CLI.

The PR appears safe to merge; the latest changes fix the outstanding early-cancellation panic without introducing a confirmed new issue.

Summary

The PR adds Active DNS lookups for names and IPs, including bulk input, timeline and record-type filtering, pagination, and multiple output formats. It also shares pagination and time-window logic with existing commands and adds a discovery hint to view.

Reviews (7) · Last reviewed commit: "fix(dns): report an interruption instead..."

Comment thread internal/command/dns/short.go Outdated
Comment thread internal/pkg/flags/timewindow.go
Comment thread internal/command/dns/dns.go Outdated
Comment thread internal/command/dns/dns.go Outdated
@Blevene

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment thread internal/command/dns/dns.go Outdated
Comment thread internal/command/dns/short.go Outdated
@Blevene

Copy link
Copy Markdown
Collaborator Author

@greptile review

1 similar comment
@Blevene

Copy link
Copy Markdown
Collaborator Author

@greptile review

@Blevene

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment thread internal/command/dns/dns.go
Comment thread internal/command/dns/dns.go
Comment thread internal/command/dns/dns.go
@Blevene

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment thread internal/command/dns/dns.go
@Blevene

Copy link
Copy Markdown
Collaborator Author

@greptile review

@Blevene
Blevene (Blevene) changed the base branch from main to feat/collections-crud October 7, 2026 15:29
Blevene (Blevene) and others added 14 commits October 7, 2026 16:13
Deviation: this moves resolveTimeWindow and its error out of the
history command into internal/pkg/flags. It is the first function in
that package that is not a flag type or a flag error. Reason: the new
dns command takes the same --start/--end/--duration flags, and one
shared implementation avoids a copy; internal/pkg/datetime holds only
parsing and time zones and has no error types. The logic and the error
message are unchanged; the history tests protect the move, and the
resolver now has its own unit tests.

The tags app package keeps its own NewInvalidTimeWindowError. It checks
filter pairs (created-before/created-after), lives in the app layer,
and has a different message, so it stays separate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deviation: this adds a new identifier type. Reason: cencli has no FQDN
type, and reusing NewWebPropertyID(...).Hostname would accept
"example.com:8080" and drop the port without an error. DomainName
rejects a port and bracketed IPv6, and accepts defanged names and
pasted URLs. Its errors use the same "invalid <type>: %q" prefix as the
other id constructors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the four Active DNS operations (name and IP, bounds and ranges)
with positional arguments, retry, and response metadata, as the other
GlobalDataClient methods do.

Deviation: the methods take record types as []string, not SDK enums,
and a small generic helper (dnsRecordTypes) converts them. Reason: the
SDK generates four different enum types for the same values, one for
each operation; plain strings keep those types inside the client, and
the helper avoids four copies of the conversion loop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds lookups for the records of a name and for the names that resolved
to an IP, each as aggregated rows or as observed time ranges. The
service validates record types for each direction (sets sourced from
the SDK enums), pages with the shared paginator, and replaces a 403
with a message that names the Search and Core plan requirement. The
four lookups share one private paging helper.

Deviation: validatePaginationParams, NewInvalidPaginationParamsError,
and optionalInt64 are copied from tags; search and tags each own their
copy. Reason: the repo keeps these per package, and sharing them would
add a refactor of search and tags that this feature does not need.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds `censys dns <name|ip>`. An IP looks up the domain names that
resolved to it; a name looks up its A, AAAA, MX, NS, SOA, and TXT
records. --timeline shows each observed time range. The command takes
the same --start/--end/--duration flags as history (default 7d), plus
--record-type and --page-size/--max-pages. The table always shows the
time window, and a note says when --max-pages cut the results short.

Deviation: the default output is the short table, not JSON as in
history. Reason: the closest precedents (censeye, tags, aggregate)
show a table first; JSON, YAML, tree, and --streaming stay available.

Deviation: input detection does not use assets.AssetClassifier.
Reason: the classifier has no domain-name type and reads a bare name as
a web property on port 443; adding a domain type would change what
view and history do with that input. The command still splits the
input like history and gives the same Too Many Assets error for a list.

Deviation: an invalid domain name keeps the constructor's reason in
the Invalid Asset ID error (for example "remove the port"), where other
callers use a fixed reason. Reason: the reason tells the user how to fix
the input.

Deviation: parsePaginationFlags and the fetch-all-pages warning are
copied from tags and search; search and tags each own their copy.
Reason: the repo keeps these per command, and sharing them would add a
refactor of search and tags that this feature does not need.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The invalid-port fixture compares stderr with the help golden after
the command's description, which is 13 lines for dns (view's is 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The gif lines are omitted from README.md and DNS.md because vhs was
not available to record them; the tapes are registered in
cmd/examples, so `go run ./cmd/examples dns` can record them later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… output

A pasted URL whose host is an IP, such as https://8.8.8.8/, was silently
routed to the domain-name lookup instead of the IP lookup: NewDomainName
strips the scheme and path and returns a bare IP-shaped string, which
looked like a valid name. parseInput now retries NewHostID on the parsed
name and switches to the IP direction when that succeeds, so both plain
and defanged IP-in-URL inputs (https://8.8.8.8/, hxxp://1.2.3[.]4/x) now
resolve as IPs.

A bare CIDR range such as 8.8.8.8/32 or 10.0.0.0/24 has no single IP to
look up, so parseInput now rejects it before any API call with a message
telling the user to give one IP address. A URL with a numeric path (e.g.
https://8.8.8.8/32) is not mistaken for a CIDR, since it carries a scheme.

The short-table truncation note ("Showing N of M records. Use --max-pages
-1 to fetch all.") used to appear any time the API's total exceeded what
was fetched, even when that advice didn't apply: with --max-pages -1 the
user is already fetching everything, and when a later page failed the
partial-error message printed after the table already explains the gap.
The note now only prints when a finite --max-pages was set and no partial
error occurred. The docs note was updated to match.

The ip-short e2e fixture now passes -p 1 so it makes one page's worth of
calls and finishes reliably within its timeout.

A TXT record's value could contain a newline, carriage return, or tab,
which would break the DNS table's one-row-per-record layout. The TXT
column now replaces those characters with a space before truncating, and
truncates by rune rather than by byte so a multi-byte character is never
split.

NewDomainName now rejects a name containing '@' and a name with an empty
label (an inner "..", or a second trailing dot such as "censys.com.."),
both with a clear reason in the error message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Test tables now use one field per line, matching the other table
tests in the repo.

DNS.md now says the truncation note is suppressed by --quiet and
that a CIDR range is rejected.

Deviation: when --max-pages stops the fetch before all records
arrive, dns prints a note to stderr ("Showing N of M records. Use
--max-pages -1 to fetch all.") in addition to the "(n of M)" count
in the title. Reason: tags shows truncation only in the title; a DNS
lookup of a busy IP can have tens of thousands of records, so the
note names the flag that fetches the rest. The note is suppressed by
--quiet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A non-positive --duration (e.g. -1h or 0s) could produce a start time after
the end time, or a zero-length window, whenever only one of --start/--end
(or neither) was given, leaving the API to reject the request with a 400.
ResolveTimeWindow now rejects a --duration <= 0 in the branches that use it,
before any request is made. This also applies to history, which shares the
same resolver, so it now rejects a non-positive duration up front instead of
letting the API return a 400.

The short table only stripped newline, carriage return, and tab from TXT
values, so any other control character (an ESC byte, a BEL, or a C1 control)
in a TXT value, domain name, mail server, name server, or SOA name from the
API could reach the terminal unescaped. Every string cell the short table
prints now goes through one sanitizer that removes control characters,
applied once at the column level rather than per record type.

A pasted URL with a comma in its path or query (e.g. https://censys.com/a,b)
was split on the comma before parsing and rejected as too many assets. The
raw argument is now refanged and checked for a scheme first; a URL is kept
as a single input, and a plain comma-separated list still splits and is
rejected as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…truncating

Detect a pasted URL by checking the raw argument for a literal "//" instead
of refanging it first: refang.RefangURL prepends "http://" to some bare
inputs, so a comma-separated list like "8.8.8.8,example.com" or
"www.censys.com,api.censys.com" was misread as one pasted URL and reached
the API as a single name instead of being rejected as too many assets.
NewDomainName also now rejects any comma left in a name after its scheme and
path are stripped, as a backstop, while still accepting a comma that only
appears in a URL's path.

Sanitize a TXT record's value before truncating it for the table instead of
after: control characters used to count against the truncation width, so a
value padded with enough of them could be cut down to nothing visible before
sanitizeCell ever ran.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add e2e fixtures for the `dns` command's input-rejection paths that fail
before any API call is made: a bracketed IPv6 address, a CIDR range, four
comma-separated asset lists, an email-shaped input, an empty DNS label,
and a negative duration. Each asserts the error title line and a Contains
check on the reason, following the existing invalid-port fixture's style.

The invalid --record-type case (`-r MX`) is included too, but needs
NeedsAuth: true: PreRun builds the DNS client before Run validates
--record-type, so with no client configured the command fails earlier
with "Censys Client Not Configured" instead of the record-type
rejection. Configuring auth lets the client build so the record-type
check, which still runs before any list request, is what actually gets
exercised.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
RefangURL did not recognize the bracketed-scheme defang form ([://]),
as in hxxps[://]censys[.]com. Neither the "looks like a URL" guard nor
the replacement logic handled it, so the input passed through
unmodified and downstream parsing treated the brackets and slashes as
part of the hostname.

Add [://] to the URL-likeness detection alongside the other defang
markers, and replace it with :// in the same place and style as the
existing [:] handling.

Deviation: this changes the shared refang package, which the dns
feature otherwise does not touch. Reason: the bracketed-scheme defang
form ([://]) was not recognized by any command; dns showed it worst,
sending a malformed name to the API. Fixing it in RefangURL fixes
every caller (dns, view, history, tags, and censeye), and the refang
tests cover the new form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… CIDRs

Move --record-type validation in the dns command's PreRun ahead of the DNS
service lookup, by exporting internal/app/dns.ValidateRecordTypes (reusing
the existing normalizeRecordTypes and name/IP type sets). Previously the
service resolved the API client first, so an unsupported record type on an
unauthenticated run failed with "Censys Client Not Configured" instead of
the actual record-type error; the service keeps its own validation as
defense in depth for direct callers.

Refang the IP part of a candidate CIDR range (as NewHostID already does)
before checking it with net.ParseIP, so a defanged CIDR such as
"8[.]8[.]8[.]8/32" or "10.0.0[.]0/24" is rejected with the same "a CIDR
range is not supported" error instead of silently dropping the prefix and
looking up the wrong IP.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Blevene (Blevene) and others added 11 commits October 7, 2026 16:13
Add a --domain flag that narrows an IP timeline lookup ("when did this
domain point at this IP") to one domain name. The Active DNS API only
supports this filter on the IP-ranges operation, so the client's
ListDNSIPResolutionRanges gained a domain mo.Option[string] parameter
(between recordTypes and pageSize) that maps to the SDK request's Domain
field; the other three DNS lookups are unaffected and mocks were
regenerated for the new client method signature.

The service's Params struct carries the filter as
mo.Option[assets.DomainName], matching how a domain name is already typed
elsewhere in this package, and only IPResolutionRanges forwards it to the
client.

The command validates --domain before resolving a service: it only
applies to an IP input with --timeline, since that is the only lookup the
API supports it on, and any other combination is a usage error before any
API call. The value is parsed with assets.NewDomainName, so defanged
input normalizes and an invalid name gets the usual Invalid Asset ID
error. Added a documented example and a help-text test, and covered the
flag with e2e fixtures for the two new usage errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dns now takes several inputs: positional arguments, comma lists (an
argument that contains "//" stays one URL input), and -i/--input-file
(a path, or - for STDIN), which overrides the positional arguments as
in view. Every input is parsed before any API call, so one invalid
input fails the command with that input's error. Inputs are
de-duplicated by their normalized form, and one command accepts at
most 100. Lookups run one after another, each with its own direction
(name or IP), --timeline, and pagination limits. A failed input does
not stop the others: its error is printed to stderr, prefixed with the
input, after the output, and the command exits 0 when at least one
input succeeded. A 403 (plan restriction) stops at once, because every
other input would fail the same way. Data output is one flat list of
all records in input order, and every JSON record now has an "input"
field with the name or IP it answers; --streaming emits one NDJSON line
per record with the same field. Short output for one input is
unchanged; several inputs print the time window once, then one section
per input.

Deviation: dns now accepts several inputs, where history takes one.
Reason: DNS lookups are often run over a list of indicators; view
already accepts several inputs and --input-file, and dns follows that.
The API has no bulk operation, so each input is its own lookup, capped
at 100 inputs per command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a "dns" template entity for the dns command's -O template output,
alongside the existing host/certificate/webproperty/searchresult
entities. The default template, internal/config/templates/dns.hbs,
renders each record in the flat array the command's data output
already prints (one input, so a lookup over several names or IPs
still distinguishes their records): the input, the record type, the
type-specific value (A/AAAA ip; MX priority and mail server; NS name
server; SOA mname and rname; TXT value; IP records: domain), and the
first/last seen time, using first_observed/last_observed for a
--timeline lookup and first_seen/last_seen otherwise.

The dns command now advertises OutputTypeTemplate and renders through
config.TemplateEntityDNS via the same record slice its JSON/YAML
output uses, so the template sees exactly the API's field names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d tags

A plan-restricted (403) lookup in the middle of a bulk run now prints the
records already collected before returning the error, instead of discarding
them; a 403 on the first input still fails immediately, since there is
nothing to keep. When every input fails, stderr now lists the failures in
input order (all but the last are printed inline, and the last is returned,
since a returned error is what the caller prints right after this command's
own stderr output). A context cancellation mid-loop now prints how many
inputs were never looked up, instead of stopping silently.

Several names or IPs can be given as positional arguments, comma-separated
lists, or via --input-file, and the help text and the --input-file
description now say so. This corrects an earlier credit: several positional
arguments follow the pattern in tags assign/unassign, and --input-file and
comma lists in positional arguments follow view; a file's lines are read
as-is and are never comma-split, so a line containing a comma is one
(invalid, multi-name) input.

A --record-type rejected only because an IP is among the inputs (e.g. MX
with a mixed name/IP list) now says so, instead of reading as if the type
were unsupported outright.

Several inputs now print one combined response-metadata block after all
lookups (the last response's method/URL/status, and the latency and page
count summed across every lookup) instead of one block per input.

Added tests pinning the exact one-input short-output layout (with and
without records) and confirming an MX record's priority of 0 still renders
in the handlebars template.

Deviation: the four DNS record wrappers define MarshalJSON. Reason: the
SDK's DNS record types define their own MarshalJSON, so a wrapper that
embeds one would inherit it and drop the "input" field; the wrapper writes
"input" and then the SDK's own encoding. All output formats (json, yaml,
tree, NDJSON) go through it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
After `view` renders short output for exactly one host, print a one-line
hint to stderr pointing at the new `censys dns <ip>` command, using the IP
as view normalized it. The hint is suppressed by --quiet, and it never
appears for json/yaml/tree/template output, streaming mode, certificates,
web properties, or a view of more than one host.

Deviation: this changes the view command, which the dns feature otherwise
does not touch. Reason: a hint on the command users already run for a host
makes the new dns command discoverable. The hint goes to stderr only, only
for short output of a single host, and --quiet suppresses it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bring DNS.md up to date with everything the command now does: several
names or IPs in one call (arguments, comma lists, --input-file, the
100-input cap, de-duplication, and that every input is validated before
any API call), the defanged and pasted-URL forms that are accepted or
rejected (including a defanged CIDR range, a port, brackets, "@", and
an empty label), --domain, template output, and how several inputs
behave in the short table, JSON/YAML/NDJSON, and on a partial or
plan-restricted failure.

Add the same --domain example and a short-output tip note to
VIEW.md, add "dns" to GLOBAL_CONFIGURATION.md's template-entity table
and list, and describe the command's bulk/format support and gif in
README.md.

Record examples/dns/dns.gif and examples/dns/dns-timeline.gif with
vhs and link them from DNS.md and README.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e output

The template engine looks up a field that a record lacks in the parent
context, the list of records, so the default dns template printed a
missing field collected from every other record (for example
"Value: [10] [aspmx.l.google.com]" on an A record). RenderTemplate now
passes each record as a map that holds every JSON field of the four
record types plus "input", nil when the record lacks it, so a lookup
stops at the record. This also protects user-supplied templates.

Every string in those maps is stripped of control characters, as in the
short table, and the default template prints API strings unescaped.
Each map also has a "timeline" flag, so the template labels IP timeline
records (which carry only first_seen/last_seen) "First Observed" and
"Last Observed", as the short output does.

A failed input followed by a plan error (403), with no input succeeded,
now prints the earlier failures in input order before the 403 is
returned. A CIDR range with a defanged slash (8.8.8.8[/]32) is rejected
like any other CIDR range instead of being looked up as its IP. The
interrupt line says "1 input" in the singular.

The view hint to run censys dns now needs exactly one host input and
one host returned, and names the returned host's IP.

The DNS docs state the exit rule and the error order exactly, and
describe input-file trimming, the defanged-slash CIDR form, and how
template values and the default template file are handled.

Deviation: the default dns template prints API strings with triple braces (no HTML escaping), unlike the other shipped templates. Reason: TXT, SPF, and DKIM values contain quotes and ampersands that HTML escaping would mangle on a terminal; every string is stripped of control characters before rendering, so nothing unsafe reaches the terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a bulk lookup's context is cancelled after an input's first page
already came back, that input's fetch can return successfully with
partial records and a page-level interruption error, rather than a
fetch error. The loop previously only checked for cancellation after a
fetch failed outright, so in this case it went on to start the next
input's lookup, which failed before any request went out and only then
reported the interruption. The loop now checks the context at the top
of every iteration, before starting an input, so a cancelled bulk
lookup stops immediately and reports the correct remaining count
without an extra, doomed request.

An explicitly empty --domain (for example --domain "") was read the
same as an absent flag, silently widening an IP timeline to every
domain instead of one. --domain is only ever meant to narrow a lookup,
so a blank value now fails with a usage error instead of being
accepted. Deviation: an explicitly empty --domain is a usage error,
where tags treats a blank filter value as absent. Reason: --domain only
narrows an IP timeline, so treating an empty value (for example an
unset shell variable) as absent would silently widen the lookup to
every domain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s looked up

The no-results path assumed at least one failure had been recorded, but the
loop can stop before any input's lookup starts (a cancelled context caught by
the loop-top guard on the first iteration) or right after the only attempted
input fails and cancels the context. In both cases the failures slice can be
empty, and indexing failures[len(failures)-1] panicked.

Track whether the loop stopped because the context was cancelled. When it
was, print every failure collected so far in input order and return the
interruption itself, so the exit code reflects the interruption (130) rather
than an unrelated failure or a panic. When there is no interruption, keep the
existing behavior of printing every failure but the last and returning the
last. The remaining case (no failures and no interruption) can't happen given
the loop's structure, but returns a generic error instead of indexing an
empty slice, just in case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PR #89 moved the tags paginator to internal/app/pagination with the
type names Page and Result. This branch had made the same move with
the names PageData and Paginated. The dns service now uses the #89
names, and this branch no longer carries its own copy of the move.

The two paginator tests that only this branch had (empty first page
and streaming mode) move into the shared test file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR #89 moved the pagination param check, its error, OptionalInt64, and
--page-size/--max-pages parsing into internal/app/pagination and
internal/command. dns had its own identical copies of all four; it now
uses the shared ones.

The fetch-all-pages warning stays local: with several inputs it says
the fetch repeats for each one, which the shared warning cannot say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant