Skip to content
uhsearPublic

About

Audit ArcGIS Online web maps for layers their viewers cannot see. Finds broken sharing, dead services, and public maps pointing at secured data.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Latest commit

 

History

12 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

sightline

Audit ArcGIS Online web maps, and the apps that embed them, for viewers who cannot open them.

A team publishes a storm dashboard for the public. The dashboard is shared with everyone. The web map inside it is shared with the organization only, because the map was built first, for staff, and nobody shared it again. Every person in the building opens the dashboard and sees the map, because everyone in the building is signed in. The public opens the same link and sees the dashboard frame around a grey rectangle. No error is raised. The dashboard loads, the map widget does not, and nobody who could fix it ever sees the problem.

A sharing audit that reads each item on its own passes this. The map is consistent with its layers. The dashboard is consistent with itself. The fault is on the edge between them: the app reaches a wider audience than the map it embeds.

sightline checks the edges it can find, from the layer up to the app. For every layer in every web map, it answers one question:

Is every viewer of the map also a viewer of the layer?

With --edges, it asks the same question one level up:

Is every viewer of the app also a viewer of the web map it embeds?

With --wcag, it also runs six accessibility rules over the same web maps.

This is a run of main() against the synthetic portal that the self-test builds. The portal object is a fake and the services are a local stub, so no portal and no login were involved. The output path is shortened to demo.xlsx; everything else is as printed:

Connected to Test Org as admin
Scanning 3 web maps...
  3/3 maps  0s elapsed  ~0s left
Read 2 apps (0 unreadable)
6 layer refs, 2 distinct layer items

3 findings of 6 layer refs in 0s -> demo.xlsx
  OK       3
  BROKEN   2
  DEAD     1
  2 app edges, 1 not openable by every viewer
  4 accessibility findings

The Edges sheet of that run held two rows, worst first:

1 High  BROKEN  Dashboard  Public board -> Staff map   app is shared public but the web map is shared org
-       OK      StoryMap   Org story -> Visitor map     every viewer of the app can load the web map

What already exists

Esri's item pages show the relationship between an app and its map. Esri's ArcGIS blog post "Discover how your layers, scenes, maps and apps connect" describes "Items used" and "Used by" sections on item pages. Use them to see what depends on what. I found no sharing comparison between the two ends in them, but I could not open the post itself, so check the item page in your own organization.

The ArcGIS API for Python has an itemgraph module. It builds a directed graph of the items an item requires and the items that require it. Its examples include a web map inside a StoryMap. Its documentation page does not describe a sharing comparison. If you need the full graph, use it.

GitHub has community tools in the same area. A repository search on 2026-10-22 found these, and I read only their descriptions. I did not run them.

JoeGuzi/ArcGIS-Online-Web-Application-Audit (last pushed 2025-05-20) is the closest match. Its description says it audits the web applications in an organization, finds the web map each one uses, compares the sharing of the app and the map, and writes the result to Excel. It also writes a second sheet of web maps that no application uses. It has no licence file. I read its description and the head of its README and did not run it, so I cannot say how it decides. If you only need an app against map comparison and the unused-map list, try it first. sightline differs in what it states: it compares group sets by group id, it reports UNKNOWN where it cannot tell instead of a silent OK, it also checks each layer against its map, it adds the accessibility rules, and it exits 3 when the run was incomplete. I did not test the other tool against any of these points.

sightline reads the live portal for its edges. The OSS plan for this tool named a different source for them, the dependency graph of whobreaks and the sharewatch snapshot. I chose the live read so that the answer is the sharing today, at the cost of one extra pass over the apps.

That list shows only what the search found. A search for the name sightline on 2026-10-29 returned other projects with that name (analytics, a game, a drone search tool). None audits ArcGIS content.

For the accessibility rules, the Massachusetts GIS accessibility guidance for web maps gives guidance for people who build web maps. A browser tool such as axe checks a rendered page. sightline checks what is in the web map's JSON, before anything is drawn.

Install

--self-test needs Python 3.9 or newer and nothing else. A live run needs arcgis, pandas and openpyxl. ArcGIS Pro ships all three, so you can use its interpreter and install nothing:

& "C:\Program Files\ArcGIS\Pro\bin\Python\envs\arcgispro-py3\python.exe" sightline.py --self-test

Otherwise:

pip install arcgis pandas openpyxl
python sightline.py --self-test

--self-test runs the assertion suite with no portal and no credentials. It starts a small web server on 127.0.0.1 and talks to that. If the last line reads 0 failed and no SKIP line sits above it, the install is good. A SKIP line means pandas or openpyxl is missing, so the Excel report was not tested. That run still exits 0: read the SKIP line, not only the exit code. --self-check is the old name for the same flag and still works.

Before it prints a single PASS, the self-test checks its own harness with plain comparisons, not with check(): a check() that always passes, a raises() that never fails, or a footer that exits 0 after a failure stops the run with the message self-test harness defect.

Use

python sightline.py --limit 10     # smoke-test first
python sightline.py                # the real run
python sightline.py --deep         # also collect feature counts and last-edit dates
python sightline.py --edges        # also flag apps shared wider than their web map
python sightline.py --wcag         # also run the accessibility rule pack
python sightline.py -o report.xlsx # choose the output path
python sightline.py -o report.xlsx --apply   # replace report.xlsx if it exists
Flag Default What it does
-o, --out ~/Downloads/sightline_<time>.xlsx The report path.
--limit N (--max) all Cap the web maps scanned, and the apps scanned per item type.
--deep off Add FeatureCount and LastEditUTC to the AllLayers sheet.
--edges off Add the Edges sheet: apps shared wider than the web map they embed.
--wcag off Add the WCAG sheet: the accessibility rule pack.
--apply off Replace the output file when it already exists.
--self-test (--self-check) off Run the offline assertions and exit.

Every flag must be spelled in full. The parser sets allow_abbrev=False, so --ap is refused with exit code 2 instead of being read as --apply, and --self-ch is refused instead of being read as --self-check.

sightline is read-only toward your organization. The one thing it writes is the report. A new report path needs no flag. An existing file is never replaced without --apply. These checks run before the audit starts, so a refusal cannot cost you a finished run: -o is not empty, -o does not name a folder (an existing folder, or a name that ends in a slash), its folder exists, and the file does not exist unless you gave --apply. If a file appears at the path during the audit, sightline still never replaces it without --apply. It writes <name>_HHMMSS.xlsx beside it instead and prints that name.

Exit codes:

Code Meaning
0 A complete run, or a green self-test (also one that printed SKIP). Findings do not change the code.
1 A red self-test, a missing library, or any uncaught error such as a failed sign-in.
2 A refused flag, or a refused -o: empty, a folder, a file that exists without --apply, or a folder that does not exist.
3 The report was written, but the run is incomplete. The console prints INCOMPLETE and the reasons: no web maps were found; a web map or app could not be read, or came back empty; item ids could not be looked up; items have no group data; or the account lacks the portal:admin:viewItems privilege.

A scheduled task can test for 3 to tell a run that could not audit everything from a clean one.

Credentials resolve as a pair, and none of them is a file sightline writes. Username comes from USERNAME in the config block, or ARCGIS_USER if that is blank; password from PASSWORD, or ARCGIS_PASS. Then:

  1. Both found, it signs in with them.
  2. No username anywhere, it uses your signed-in ArcGIS Pro session, and prompts if there is none.
  3. Username but no password, it prompts for the password.

ARCGIS_URL overrides PORTAL_URL, because PORTAL_URL always holds a value.

Run it as an org administrator. A standard user only sees their own content, so the audit covers a fraction of the org and everything else reads as clean.

Scheduled tasks need a Python without ArcPy. sightline imports the arcgis package when it signs in. That package probes for ArcPy when it loads. Under a non-interactive scheduled task, the ArcGIS Pro licence may not be initialised. The import then fails with "The Product License has not been initialized", before sightline reaches your portal. The ArcGIS Pro Python has ArcPy, so it has this problem. For a scheduled run, use a Python that has arcgis installed but not arcpy. Set ARCGIS_USER and ARCGIS_PASS for that run too. Without ArcPy there is no Pro session to sign in with, and a task cannot answer a prompt.

If the task must use the ArcGIS Pro Python, block ArcPy before arcgis loads. An import hook that raises ImportError for arcpy makes the package use an engine without ArcPy. sightline does not install this hook itself. Save this wrapper next to sightline.py as run_sightline.py, and point the task at the wrapper:

import builtins, os, runpy

os.environ.setdefault("ARCGIS_IO_ENGINE", "shapefile")
_real_import = builtins.__import__

def _no_arcpy(name, *args, **kwargs):
    if name.split(".")[0] in ("arcpy", "arcgisscripting"):
        raise ImportError("%s blocked for a scheduled run" % name)
    return _real_import(name, *args, **kwargs)

builtins.__import__ = _no_arcpy
here = os.path.dirname(os.path.abspath(__file__))
runpy.run_path(os.path.join(here, "sightline.py"), run_name="__main__")

ARCGIS_IO_ENGINE=shapefile stops the package from choosing ArcPy for file work. The hook also removes Pro sign-in, so ARCGIS_USER and ARCGIS_PASS are still required.

Configure

Everything tunable sits in one CONFIG block at the top of the script. Command-line flags override the matching value.

Setting Default What it does
PORTAL_URL https://www.arcgis.com Set to your portal URL for ArcGIS Enterprise
USERNAME / PASSWORD blank Leave blank, prefer env vars or the prompt
MAX_MAPS 10000 Ceiling on web maps fetched
MAX_APPS 10000 Ceiling on apps fetched, per item type (--edges)
DEFAULT_LIMIT None Cap every run without passing --limit
DEEP_DEFAULT False Always collect feature counts
OUTPUT_DIR ~/Downloads Where the report goes
OUTPUT_PREFIX sightline Report filename is <prefix>_YYYYmmdd_HHMMSS.xlsx
MAX_COL_WIDTH 55 Excel column width cap
WIDTH_SAMPLE_ROWS 400 Rows sampled when sizing columns
PROBE_TIMEOUT 8 Seconds per service URL
MAX_HOST_FAILS 3 Failures before a host is skipped
SECURED_CODES {401,403,498,499} REST codes meaning "needs a token"
DEAD_CODES {400,404} REST codes meaning "not there"
ITEM_CHUNK 50 Item ids per batched lookup
GROUP_CHUNK 80 Item ids per content/itemsgroups call
SEV public = High Severity band per audience
EDGE_TYPES four item types Item types searched for --edges
MAP_KEYS four JSON keys Keys that may hold an embedded web map id
PLACEHOLDER_TITLES six titles Titles the map-title-placeholder rule flags

Do not put a real password in PASSWORD. A password typed into a script is a password you will eventually commit.

The report

Three sheets, plus one for each of --edges and --wcag:

Findings is every layer reference that is not OK, sorted worst first. This is the work queue.

AllLayers is every layer reference examined, including healthy ones, for pivoting.

Summary carries run metadata, verdict totals, the worst maps, and the owners with the most problems. That last table is who you email. With --edges it also counts edge verdicts. With --wcag it counts findings per rule.

Edges (--edges) is every app-to-map edge, findings first. Severity follows the app's audience, because the app is the link people open.

WCAG (--wcag) is one row per rule hit, with the rule, the WCAG criterion, the layer and the reason. Severity follows the map's audience.

Verdicts

Verdict Meaning
OK Every map viewer can load the layer
BROKEN Some map viewers cannot load the layer
DEAD The layer's service is gone, or its item was not found by the signed-in account. The item may be deleted, or the account may not be able to see it. The Reason column says which
UNKNOWN Could not be determined. The Reason column says why

Severity follows the map's audience, because that is who is affected: 1 High public, 2 Medium org, 3 Low shared, 4 Info private. Findings sorts by severity then view count, so the most-visited broken public map is the first row.

A web map that cannot be read is one UNKNOWN row with Section <error>, so it appears in Findings. The arcgis package's Item.get_data hides a failed read (a timeout, a 5xx, a 403) and returns {} (read in the installed arcgis 2.4.2 package, Item.get_data). So an empty answer, a non-JSON answer, or JSON with none of operationalLayers, baseMap and tables is treated as an unreadable map, never as a map with no layers. An app is judged the same way: an empty or non-JSON answer is an unreadable app.

The Overexposed column tracks a separate problem: a public layer inside a non-public map. Nothing is broken, but the data is shared more widely than its map implies.

How it decides

Sharing is audience containment, not a severity rank. The common shortcut (private < org = shared < public) hides real breakage, because org and shared are incomparable audiences. An org member outside the group cannot load a group-shared layer, so an org-wide map with a group-shared layer is broken for most of its audience.

Layer Map Result
public anything OK
org private OK
shared private, same owner OK
shared private, different owner UNKNOWN: the map owner's group membership is not read
private private, same owner OK
private private, different owner BROKEN
org public BROKEN
org org OK
org shared UNKNOWN
shared org or public BROKEN
shared shared Compare group sets
shared shared, groups unknown UNKNOWN
private org, shared or public BROKEN

An item shared to zero groups normalizes to private, since its real audience is the owner. This holds for the map and for the layer. A layer shared to no group, owned by someone else, is therefore BROKEN in a private map.

An org layer in a group-shared map is UNKNOWN, not OK. Esri lets a group take members from other organizations. The Share items page says: "Members of the group can be from the same organization or different organizations." It also says that sharing with your organization means only members of your organization can access the item, so a member of another organization cannot load an org-only layer. (I read the page on 2026-10-29. It states the first sentence and the second one directly; the conclusion about a group member from outside is mine.) sightline does not read group members, so it cannot say that every member belongs to your organization.

Group sets are compared by group id, never by title. Two groups can have the same title, for example two "GIS Staff" groups made by different owners. A comparison by title would call them the same audience. Titles appear in the report only as labels.

Layers that have no url by design

A layer with no url, no itemId and no featureCollection used to read as UNKNOWN at the map's severity, which put every public map with the OpenStreetMap base map in the work queue. The Esri web map specification gives some layer types no url property at all:

  • OpenStreetMap, BingMapsAerial, BingMapsHybrid and BingMapsRoad have none (see the OpenStreetMap layer and Bing layer pages). sightline scores them OK: there is no item or service of the organization to share.
  • WebTiledLayer keeps its address in templateUrl, and VectorTileLayer in styleUrl (see the web tiled layer and vector tile layer pages). A template holds {level}, {col} and {row} placeholders, so it cannot be probed. sightline scores these UNKNOWN with the reason tile layer addressed by a template or style url, and does not call them OK.
  • A group layer whose children are all embedded feature collections, or whose children are group layers, is followed to those children. Only a layer with no address of any kind is still NO_TARGET.

Layers that are not items

Many layer references are not ArcGIS Online items at all. They are registered service URLs, on-premises server layers and basemaps. Tools that only compare item permissions mark all of them fine.

sightline fetches each distinct service URL anonymously, with no token, and reads what the public would get:

Response Meaning
Normal The layer really is public
401 403 498 499 Needs a token. In a public map this is BROKEN
400 404 The service is gone (ArcGIS REST services only)
A 200 reply whose error is not an object with a code UNKNOWN, reason error reply of unknown shape
Connection failure UNKNOWN, with the reason recorded

Only an ArcGIS REST service is probed: a layer whose layerType starts with ArcGIS, or whose url holds /rest/services/, or a layer with no layerType at all. A WMS, KML, CSV or other OGC layer reads UNKNOWN, reason not an ArcGIS REST service; not probed. The 400 rule was measured on ArcGIS services only. A healthy OGC server answers 400 to a request that lacks its own parameters, so the rule would call a working WMS layer DEAD.

This catches a map titled "Internal" that is shared publicly and points at token-secured services. No data leaks, but the map is broken for every visitor.

The probe sends the layer's own query string, because a layer built from a .../query?where=... url needs it. It replaces any f= parameter with f=json. It drops every parameter whose name contains token, key, pass, pwd, secret, sig, auth or credential, and it drops any user:password@ part, so the probe sends no credential. The report shows the same cleaned url in the LayerUrl column, in its hyperlink and in the WCAG sheet. The name test is a heuristic. A secret in a parameter with another name is still copied.

TLS verification stays on. A certificate a browser would reject is a finding, not something to suppress.

App edges (--edges)

--edges searches four item types, named as the Esri Items and item types page spells them: Dashboard, Web Mapping Application, StoryMap and Web Experience (Experience Builder). It reads each app's JSON once.

The app is treated as the "map" in the table above, and the embedded web map is treated as the "layer". Read the table below as: the row is the web map's audience, the column is the app's.

Web map is App public App org App shared App private
public OK OK OK OK
org BROKEN OK UNKNOWN OK
shared BROKEN BROKEN Compare group sets OK if the owner is the same, else UNKNOWN
private BROKEN BROKEN BROKEN OK if the owner is the same

The same rules apply as for layers. An item shared to zero groups is private, on either side. Unknown group membership on either side is UNKNOWN, never a silent OK. An org web map under a group-shared app is UNKNOWN, for the reason given in the layer table.

How an embedded map is found. Esri documents the data of these item types only in part. The item-type page above lists a Text/JSON data file for Dashboard, Web Mapping Application and Web Experience, and Text/JSON with File for StoryMap. It gives no key names for any of them. Its one property for these types is the service url of a Web Mapping Application, kept in the item's url. Esri's configurable templates page (the 10.5 version) describes a values object that holds settings such as a web map id. I found no official shape for Dashboard, StoryMap or Experience Builder data. So sightline does not parse one shape per type. It collects every 32-digit hex string held under the key itemId, webmap, webmapId or mapId anywhere in the app's JSON. It then looks those ids up. It keeps an id as an edge only when it resolves to an item of type Web Map. A layer id or a scene id is counted in the Summary and dropped. An id that resolves to no item, or whose lookup failed, becomes an UNKNOWN row at the app's severity: it may be the web map this app is shared wider than, and a missing row would read as a clean result. A failed lookup also prints INCOMPLETE and exits 3.

This has one consequence you must know. A key I did not list is a missed edge, not a wrong one. The edges sightline reports are real. The edges it misses are unknown.

Accessibility rules (--wcag)

The rules read the web map's JSON and its item summary. Nothing is drawn. Each rule names the WCAG 2.2 success criterion it serves. A clean run is not a conformance claim. These are the checks a web map's JSON can answer.

Rule WCAG Level Fires when
map-title-placeholder 2.4.2 Page Titled A The item title is empty, or one of PLACEHOLDER_TITLES such as "Untitled Map".
map-no-summary 1.1.1 Non-text Content A The item has no summary and no description.
layer-no-title 2.4.6 Headings and Labels AA A layer has an empty or blank title.
layer-popup-off 1.1.1 Non-text Content A A layer sets disablePopup to true.
renderer-color-only 1.4.1 Use of Color A A uniqueValue renderer has two or more classes whose symbols share one shape, size and outline, and differ only by color.
label-halo-contrast 1.4.3 Contrast (Minimum) AA A drawn label has a halo, and its text and halo are under 4.5 to 1. Large text needs 3 to 1.

What each rule rests on, checked against the sources:

  • WCAG 2.2 defines 1.4.1, 1.4.3, 1.1.1, 2.4.2 and 2.4.6 as sightline uses them. The contrast ratio is (L1 + 0.05) / (L2 + 0.05), from relative luminance with the 0.04045 sRGB threshold. The W3C Contrast (Minimum) page gives 4.5 to 1 for normal text, 3 to 1 for large text, and defines large as at least 18 point, or 14 point bold.
  • The Esri web map specification says a layer title is "a user-friendly string title for the layer that can be used in a table of contents", that disablePopup indicates "whether to allow a client to ignore the popups", that labels display when showLabels is true, and that uniqueValueGroups should be used instead of uniqueValueInfos when present. sightline reads uniqueValueGroups first for that reason. Colors are four-element arrays of red, green, blue and alpha, 0 to 255. Label font.size is in points. See the layer, feature layer, unique value renderer and text symbol pages.
  • The item snippet (summary) and description are item parameters in the REST common parameters page.

What the rules choose not to do:

  • The base map is Esri's content, so its layers are never judged.
  • A color that is not fully opaque is skipped. It is drawn over a base map that sightline cannot see, so its contrast has no answer.
  • classBreaks renderers are skipped. They show an ordered value as a ramp, which is a different design question from telling nominal classes apart.
  • Picture and CIM symbols are skipped. They differ by more than color.
  • A layer with no popupInfo is not flagged. The specification lets a client use the popup that the service defines, so a missing popupInfo does not mean popups are off.
  • layer-popup-off is advisory. A reference layer may have good reason to hide its popup.

Why it is fast

The naive version of this audit took about 19 minutes against an organization of a few hundred web maps. This one takes under three. I measured the numbers below against a live organization while building the tool. The self-test cannot reproduce them, because it has no portal. Three things account for the difference:

Never touch an unknown attribute on an Item. Item.__getattr__ performs a network round trip before raising AttributeError. One hasattr() call costs 0.665 seconds. The tool sightline replaced called it twice per layer to fill two columns that were empty in every row, spending roughly 95% of its runtime in dead code.

Resolve items in batches. One id:(a OR b OR ...) search resolves 50 ids per round trip instead of one, measured 18.8 times faster with byte-identical results.

Do not reach for threads. On a shared GIS session, 4 workers gave 1.3x, 8 gave 1.1x and 16 gave 0.7x, slower than sequential. The connection serialises, so sightline stays sequential and readable.

The self-test pins the rule and the batch size, not the times. Its fake Item raises an AssertionError on any attribute that is not id, get_data or a dict key, so a full run fails if the code ever probes one. A chunk test checks that ids go out 50 at a time.

--wcag adds no request. It reads the JSON that the layer audit already fetched. --edges adds one search per item type, one read per app, the batched item lookups for the candidate ids, and content/itemsgroups calls for the app ids and the web maps they embed.

Self-test

$ python sightline.py --self-test
sightline self-test: 127.0.0.1 stub and temporary files only, no portal, no login
--------------------------------------------------------------------
PASS  check() and raises() really do record a failure  <-- pinned defect
...
PASS  a footer that exits 0 on a failure, or hides the count and the last line, is caught  <-- pinned defect
...
PASS  an org-only layer in a group-shared map is unknown, since a group can hold outside members  <-- pinned defect
...
PASS  a WMS or KML layer is unknown, never dead or public; a REST url or ArcGIS type is probed  <-- pinned defect
...
PASS  a secured service never shows LayerAccess=public in AllLayers  <-- pinned defect
...
PASS  a map with OSM, Bing, tile-template, style-url, embedded-group and nested-group layers has no false NO_TARGET finding  <-- pinned defect
...
PASS  a token already in the layer url is not sent  <-- pinned defect
...
PASS  a web map whose read came back empty, non-JSON or without map keys is an error row, not a clean map  <-- pinned defect
...
PASS  a failed lookup chunk is unresolved, never reported as deleted  <-- pinned defect
...
PASS  a group app on an org map is unknown: a group can hold members from another organisation  <-- pinned defect
...
PASS  an app whose read came back empty or non-JSON is an error row, not an app that embeds nothing  <-- pinned defect
...
PASS  an embedded id with no item is an UNKNOWN row too, so a public app on a deleted map is not clean  <-- pinned defect
...
PASS  accessibility rows sort by audience, busiest map, map title, rule and layer  <-- pinned defect
...
PASS  a prefix of the write flag, --ap, is refused  <-- pinned defect
...
PASS  an existing folder, or a name that ends in a slash, is refused with or without --apply  <-- pinned defect
...
PASS  an existing report is not replaced without --apply  <-- pinned defect
...
PASS  Findings lists the most-visited broken public map first, ties by title and layer, got [('Able', 'L1'), ('Able', 'L2'), ('Bravo', 'L1'), ('Bravo', 'L2'), ('Alpha', 'L1'), ('Alpha', 'L2'), ('Charlie', 'L1'), ('Charlie', 'L2')]
...
PASS  a map that cannot be read: the report is written and the exit code is 3, not 0  <-- pinned defect
--------------------------------------------------------------------
376 assertions, 0 failed

The full run prints all 376 assertions, each on its own PASS line. Each ... line is where this block is cut. Lines marked <-- pinned defect are cases where a real defect was found, or could return, and the assertion keeps it from coming back.

The count is the same on every host that has pandas and openpyxl, and the same on every host that lacks them:

Host Python Packages Count
Windows, ArcGIS Pro conda environment 3.13.7 pandas 3.0.3, openpyxl 3.1.5, arcgis 2.4.2 376
Windows, plain C:\Python313 3.13.2 pandas 3.0.3, openpyxl 3.1.5 (user site) 376
Linux, virtual environment 3.12.3 pandas 3.1.0, openpyxl 376
Windows, virtual environment 3.13.2 pandas 3.0.5, no openpyxl 325
Windows, plain C:\Python313 run with -I 3.13.2 none visible 325
Linux, system python3 3.12.3 none 325

No Python 3.9 interpreter was available, so Python 3.9 was not run. The self-test parses the source with ast.parse(..., feature_version=(3, 9)), and the file has no f-string.

The self-test never imports arcgis or arcpy, so they change nothing. On a Python without pandas or openpyxl, 51 Excel assertions do not run. The run prints SKIP report writer where they would be, and a second SKIP line above the footer. The count drops to 325. The run still ends 0 failed and exits 0, so read the SKIP line before you trust the footer. Windows and Linux therefore agree: 376 with the two packages, 325 without. They differ only by which packages a given interpreter can import. Plain C:\Python313 on the author's machine finds pandas in its user site folder, so a default run there prints 376; -I (or PYTHONNOUSERSITE=1) hides the user site and prints 325. The Linux production host's system python3 has no pandas, and nothing was installed on it for this work. The 376 on Linux ran in a separate virtual environment.

The PASS lines and the footer agree: a run prints one PASS line for each assertion.

coverage run --branch sightline.py --self-check reports 100 percent of 1827 statements and 410 branches. Two runs gave that figure: coverage.py 7.16.1 on Windows, with the ArcGIS Pro Python 3.13.7, and coverage.py 7.17.0 on Linux, in the virtual environment with Python 3.12.3. Both have pandas and openpyxl. In an environment with coverage.py but without openpyxl, the Excel writer and the whole-run tests cannot run, and the figure falls to about 84 percent: frame(), summarize(), save_report(), defuse(), scrub(), write_excel(), _t_report(), and the part of main() that follows the report-library load are missed.

Limits

  • Never run against a live portal in this work. The portal calls (content.search, get_data) are covered by a fake object with the same shape. The real arcgis.gis.GIS class insists on HTTPS (an http:// portal URL raised an SSLError on the first request), so a stub for it needs a throwaway certificate, which I did not make. The login, the search and the item reads are therefore unproven against a real portal. The anonymous service probe and the group lookup ran against a real local web server.
  • App JSON is undocumented. See "How an embedded map is found". The fixtures are synthetic and written from the rules above, not copied from a real app. A real Dashboard, StoryMap or Experience Builder app may keep its map id under a key that is not in MAP_KEYS.
  • content/itemsgroups is not in the REST reference. I found no page for it. The documented per-item operation is content/items/<id>/groups. The group lookup reads the reply shape that the earlier versions of sightline read. The stub proves the parser reads that shape, not that your portal sends it. The arcgis 2.4.2 sharing helper (gis/_impl/_content_manager/sharing/api.py) passes a groups= list of every group in the organization. If your portal reports only the groups the signed-in account can see, an item shared to a group the account cannot see reads as shared to no group, and is scored as owner-only. That needs a live check.
  • The 498 and 499 codes have Esri sources, but the table is measured. Esri's REST authentication operations page shows code 498 with the message "Invalid Token", and the geocode service output page shows 499 with "Token required". The set of codes sightline treats as "needs a token" (401, 403, 498, 499) and as "not there" (400, 404) was measured against live services when sightline was first built. The stub proves the table is applied, not that it is complete.
  • The Esri blog address was not opened. The post under "What already exists" returned HTTP 403 to a scripted fetch, so its title and description come from a search result.
  • Web maps and four app types. Web scenes, Hub sites and other item types are not scanned. An app that embeds a layer directly, with no web map, has no edge in this tool.
  • Group membership is item-level. sightline compares which groups a map and layer are shared to. It does not enumerate group members, so it cannot tell you a group is empty.
  • UNKNOWN is deliberate. An unreachable host may be a firewall rule rather than a defect. Check the Reason column before acting.
  • A secured service in a non-public map stays UNKNOWN. Whether your org members hold credentials for an on-premises service cannot be determined from ArcGIS Online.
  • An embedded feature collection that is an empty object counts as no target. The earlier versions did the same. It shows as UNKNOWN.
  • The --wcag description check strips tags with a pattern, not a parser. A description made only of entities such as &nbsp; counts as text.
  • The probe fetches any http or https url a web map holds. Redirects are followed, and private-network addresses are fetched too. Python's urlopen also follows a redirect from an http url to an ftp:// url, so a hostile layer url can make the machine that runs sightline open an FTP connection. A hostile web map can make that machine request an arbitrary url. Only a status is kept. Run it from a host whose network reach you accept.
  • The probe reads a whole response body. The timeout applies to each read, not to the whole reply. A service that streams forever, or sends a byte at a time, can hang the run or use all memory.
  • The probe reads any JSON object with no error key as PUBLIC. A layer with no layerType and a url outside /rest/services/ is still probed, and a non-ArcGIS service that answers JSON there reads PUBLIC. A layer with a type other than ArcGIS... is not probed (see "Layers that are not items").
  • A string error code is UNKNOWN. A reply such as {"error": {"code": "498"}} is not classed SECURED, because the code table holds integers only.
  • Malformed layer urls trip the host circuit breaker. Three urls with a space, a control character or a non-ASCII character fail as transport errors. Later good urls on the same host then read UNREACHABLE instead of being probed.
  • A layer itemId, url or title that is not a string stops the run. A number or an object there raises a TypeError or an AttributeError before any report is written.
  • An upper-case layer itemId reads as DEAD. It is looked up as given, and the search returns lower-case ids, so a live layer shows as a false DEAD.
  • Overexposed only fires for org-owned layer items. A public service found by the probe, or a public item from another organization, is never marked, which is narrower than "a public layer inside a non-public map".
  • Edges can be noisy. Dashboards and other apps keep layer ids under itemId too, so every layer item the signed-in account cannot see becomes an UNKNOWN row in Edges, at the app's severity. A web map that is private to someone else, or deleted, does the same. The Summary row candidate ids that were not web maps counts them as no item N.
  • An app that holds its map id under a key outside MAP_KEYS has no row in Edges. Absence from the sheet is not a clean result.
  • An app whose data comes back empty reads as unreadable. get_data returns {} for a failed read, and sightline cannot tell that from an item that has no data file, such as a Web Mapping Application that is only a registered url. Either becomes an UNKNOWN row and an INCOMPLETE run. I did not test what a real portal returns for an item with no data.
  • After a failed item lookup the run exits 3. Each candidate whose lookup failed is an UNKNOWN row in Edges, and the Summary counts it as lookup failed N.
  • Owner names compare as exact strings. Alice and alice are two owners.
  • A private map owned by A, with a layer shared to groups by B, reads UNKNOWN. sightline does not read whether A is in those groups, so it does not call this OK.
  • The edge reason for a public app on a group-shared map reads "shared shared". The words are awkward and the verdict is right.
  • The report path check runs once, before the audit. If the file appears or is locked during the audit, sightline does not replace it without --apply. It writes the report under a new name instead, <name>_HHMMSS.xlsx and then <name>_HHMMSS_2.xlsx, and prints the name. That is a write without --apply, though a new file only. The default output name has one-second resolution.
  • The -o check cannot tell whether the account may write the folder. A folder that exists but refuses the write fails after the audit with an error and loses the run.
  • Error text is copied into the report. The text of a failed request or read goes into the Reason column and the console as the exception gives it. An exception that echoes its request url could carry a token from a legacy token-in-query login. clean_url() cleans the layer url columns. redact() removes credential-named query values and user:password@ parts from the text of a failed group lookup, item lookup, map read or app read before it is printed or stored. It works on the parameter name, like clean_url(), so a percent-encoded name or a value under another name is not removed.
  • An item title with a lone surrogate stops the Excel write. It is reachable only through the REST API, and it raises UnicodeEncodeError at the end of the run.
  • A title longer than 32767 characters is cut. openpyxl cuts it in the workbook without a message.
  • Non-ASCII text can stop the console. Printing an organization name or an error with characters outside the console code page, such as cp1252 in a redirected scheduled run, raises UnicodeEncodeError.
  • Hyperlink cells keep the url scheme. A javascript:, file: or UNC url from a hostile web map becomes a clickable link. A url over about 2079 characters may make Excel offer to repair the file. Do not click links in a report from an organization you do not trust.
  • fetch_groups() expects the reply shape it knows. A group block that is not an object raises an AttributeError. This needs a malformed reply.
  • connect() ignores ARCGIS_PASS and ARCGIS_URL when a Pro session is found. That happens when no username is set, so the run can audit a different portal than the one configured. Only the Connected to line shows which.
  • --deep counts only layers on the portal host or an arcgis.com host. Every other layer keeps a blank FeatureCount and gets the text skipped: not a portal or arcgis.com host in LastEditUTC, so an Enterprise site with a separate server host gets no counts.
  • A run in which every service probe fails still exits 0. With the network or a proxy down, the url-only layers read UNKNOWN with a reason such as RemoteDisconnected or host circuit-open, and nothing marks the run INCOMPLETE.
  • A token inside a layer url is dropped before the probe. A token-secured service whose map url carries a valid token reads SECURED, and BROKEN in a public map, although the embedded token does let anonymous viewers load it.
  • A malformed item id goes into the search query unquoted. An id with a trailing newline (the $ in ID_RE matches before it), a quote or a parenthesis, in an app or a web map, changes the id:(a OR b ...) query for its whole chunk of 50 ids. The valid ids in that chunk can then read DEAD or UNKNOWN. It needs a hostile or corrupt item.
  • An app can keep a web map id after the widget is removed. An unused Experience Builder data source is one example. sightline reports that id as an edge, so the edges it reports are real only for ids that are still embedded.
  • A bare run with no credentials and no Pro session was seen to hang past 30 seconds once, with no report and no traceback. The cause was not isolated. It may be the GIS("pro") attempt.
  • save_report with --apply truncates the old report before it writes. A full disk during the write loses the previous report.
  • The renderer and label rules read only what the web map stores. renderer-color-only and label-halo-contrast look at layerDefinition.drawingInfo, so a layer drawn with its service's default style or labels is never judged. A clean WCAG sheet says nothing about it.
  • renderer-color-only is advisory. It fires on any unique-value layer whose classes differ only by fill color. That includes ordinary categorical maps that also have a text legend. Treat every hit like layer-popup-off.
  • The WCAG criterion numbers are the author's judgement. Success criteria 1.1.1 for map-no-summary and 2.4.2 for map-title-placeholder are written for page content, not for ArcGIS item metadata.
  • The Esri Share dialog was not checked. I could not confirm whether it warns about related items, so "I found no sharing comparison" covers the item pages and the documents I read, not Esri's whole interface.
  • Some behaviour is not pinned by any assertion. A mutation sweep left these alive: changing if errors or app_errors: in incomplete_reasons() to and (the unit check only passes both lists together); the criterion number of each rule in WCAG_RULES; the webmapId and mapId entries of MAP_KEYS; the placeholder titles new map, map and web map; the order of the Top owners rows; the FeatureCount and LastEditUTC column order; len(apps) + len(app_errors) in the app counts when no app is unreadable; and making the templateUrl, styleUrl or BUILTIN_TYPES tests in _has_target() conjunctive.
  • The contrast boundaries are not pinned. A ratio of exactly 4.5 or 3.0 (ratio < need) and the sRGB 0.04045 threshold have no fixture, because no 8-bit gray gives an exact ratio.
  • clean_url() misses some credential names. jwt, session, sessionid and bearer are not in the list, so a value under one of them stays in the report and is sent by the probe.
  • USERNAME and PASSWORD in the config block win over the environment. The safe path, an environment variable or the prompt, is a convention. Nothing enforces it.
  • The Mass.gov link was not opened by the author. The page returned HTTP 403 to a scripted fetch on 2026-10-22 and again on 2026-10-29. Check that it resolves.
  • The "Product License has not been initialized" failure is reported, not reproduced. It comes from scheduled-task experience. The self-test does not trigger it. The wrapper above ran in the self-test environment, and arcgis.gis imported with arcpy blocked.
  • Coverage on a bare interpreter is 84 percent. Without pandas and openpyxl the Excel writer and the whole-run tests cannot execute, so about 300 statements are missed. With both libraries it is 100 percent, so every line is reachable offline.
  • A run that hits a ceiling stops early and still exits 0. MAX_MAPS and MAX_APPS are 10000, which is also the hard limit of the arcgis search (it raises above 10000). --limit stops early by design. In both cases the Summary shows only maps scanned, with no note that the audit was cut short.
  • A layer url with an unmatched bracket in the host stops the run. A url such as http://[::1 makes probe() raise ValueError at the urlparse call. host() guards the same call, but probe() does not, so no report is written.
  • When the group lookup for a web map fails, a private layer in it is BROKEN. The map might really be shared to no group, so the layer would be fine. The run still prints INCOMPLETE and exits 3.
  • renderer-color-only compares sizes with repr(). Marker sizes 8 and 8.0 read as different shapes, so the rule stays silent. Fills that differ only in alpha count as "color only".
  • Only the font weight bold lifts a label to the 14 point large-text threshold. A weight such as bolder is judged as normal text and needs 4.5 to 1.
  • A layer item that resolves wins over its service. build() ignores the service probe when the layer's itemId resolves, so an item marked public is trusted even if the layer's own url is secured. This needs a mismatched item and url.
  • clean_url() removes credentials by parameter name only. A secret under a name outside the list, a percent-encoded name, a ;-separated parameter, or a token inside the path stays in the report and is sent by the probe.
  • --deep sends the session token to arcgis.com hosts. For an ArcGIS Online portal it sends it to any https host under arcgis.com named by a FeatureServer layer url in the map data. The requests package re-sends the X-Esri-Authorization header on a redirect to another host, so a redirecting trusted host could forward the token.
  • Before Python 3.11.4, urlparse keeps leading whitespace. A layer url with a leading space reads "only http and https urls are probed" there, and is probed on newer Pythons.

Contributing

Bug reports and feature requests go in GitHub issues.

Run python sightline.py --self-test before opening a pull request. The suite asserts the full classification matrix, including the rule that org and shared are incomparable, so reverting to a rank comparison fails loudly instead of quietly under-reporting.

Author

Built by Asir Khan.

License

MIT. See LICENSE.

Related

Other single-file tools in this portfolio that pair with this one:

  • sharewatch - the same platform from the other side: what CHANGED last night, rather than what a viewer sees today
  • ghostsvc - the anonymous services a viewer-side audit never reaches
  • whobreaks - what breaks if you delete one of the items it flags
  • deadwidget - the Experience Builder side: widgets bound to a layer or sublayer that no longer exists

About

Audit ArcGIS Online web maps for layers their viewers cannot see. Finds broken sharing, dead services, and public maps pointing at secured data.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages