App-Netdisco

 view release on metacpan or  search on metacpan

Changes  view on Meta::CPAN

  * fix arbitrary module loading in the SNMP browser's munge parameter
  * fix a privilege escalation in the queue API and the admin job controls,
    where a job action naming a backend command worker could be queued
  * fix an open redirect in the login form's return_url
  * warn at startup when metrics_path is set with neither metrics_allow nor
    metrics_token, so the endpoint is not published unrestricted unnoticed
  * raise the bcrypt work factor for locally stored passwords. an existing
    password is rewritten at the new factor on its owner's next successful
    login, and safe_password_store still governs whether that happens
  * bound the port count accepted when creating a pseudo device
  * fix a command injection in the external credentials command, where a
    device's DNS name was substituted into it unchecked

2.109000 - 2026-09-13

  [ENHANCEMENTS]

  * this release needs Safari 16.4, Firefox 101, Chrome 74 or Edge 79, or
    newer. it is the first that will not work on an older browser
  * pages are now drawn in the display mode the current framework is written
    for. rows in a table stand a little taller as a result

Changes  view on Meta::CPAN

  * #1478 fixes vendor not known by netdisco

2.097000 - 2025-12-16

  [NEW FEATURES]

  * IOS-XE: add SSH ARP (multi-VRF) and MAC collectors (replaces IOSXEMac collector)

  [ENHANCEMENTS]

  * #1473 get_credentials can now also open a plain file

2.096001 - 2025-12-13

  [ENHANCEMENTS]

  * #1458 add login_banner_message setting to put text on the login page

  [BUG FIXES]

  * #1440 promote bare empty string to empty array if found on list custom field

Changes  view on Meta::CPAN


  * allow ACL property match to contain ":"
  * fix skip long SNMP timeout for initial discover

2.063002 - 2023-07-14

  [ENHANCEMENTS]

  * #1059 NETDISCO_SNMP_BULKWALK_OFF environment to disable bulkwalk
  * #1060 portctl_topology setting to enable manual topology for port control users
  * #1066 faster device credentials discovery and snmp_try_slow_connect setting
  * #1067 netdisco-do --force to enqueue more than 512 jobs
  * deleting a device is now always a job, never done in the web engine
  * update netdisco-do documentation

  [BUG FIXES]

  * #1058 error when the log string is empty

2.063001 - 2023-06-28

Changes  view on Meta::CPAN

  * #539 fix an issue with VLAN reindixing for VRFs (earendilfr)
  * fix device port change check

2.041000 - 2019-03-12

  [NEW FEATURES]

  * netdisco-sshcollector no longer required - the functionality (arpnip via
    ssh) will be run within netdisco's core schedule (rc9000 and ollyg)

  * get_credentials replaces get_community and accepts any device_auth stanza
    in JSON format (ollyg)

2.040007 - 2019-03-06

  [BUG FIXES]

  * #521-redux Search Node Date Range not working (ollyg)

2.040006 - 2019-03-04

Changes  view on Meta::CPAN


  [ENHANCEMENTS]

  * Device Port filter by vlan includes both tagging and untagged (closes #37)
  * Change colour and name of Port Control toggle option in Device Port View
  * Incorporate latest DB schema changes from upstream
  * Add some doc clarifcations

  [BUG FIXES]

  * No longer prompt for demo/demo credentials (closes #38) (J. Binks)
  * Display IP in Vlan search if device has no DNS (closes #36) (J. Binks)

2.004000 - 2013-01-14

  [BUG FIXES]

  * Very naughty typo in netmap code, which I forgot to merge a fix for.
  * Typo in installation docs (S. Gupta)

2.002000 - 2013-01-06

MANIFEST  view on Meta::CPAN

xt/80-acl-type-options.t
xt/81-token-entropy.t
xt/83-csv-formula-neutralization.t
xt/84-datatables-raw-columns.t
xt/85-postfix-conditional-escaping.t
xt/86-snapshot-regenerator.t
xt/87-snmp-munger-allowlist.t
xt/88-job-action-refusal.t
xt/89-login-return-url.t
xt/90-pseudodevice-ports-and-hash-cost.t
xt/91-external-credentials-host.t
xt/92-password-rehash-on-login.t
xt/bin/regenerate-portsort-corpus
xt/bin/regenerate-snapshots
xt/js/daterange-presets.test.js
xt/js/deferred-nodes.test.js
xt/js/disco-queue.test.js
xt/js/error-reporter.test.js
xt/js/history-ownership.test.js
xt/js/navbar-submenu.test.js
xt/js/netmap-autosave.test.js

lib/App/Netdisco/Util/DeviceAuth.pm  view on Meta::CPAN


use Dancer qw/:syntax :script/;
use App::Netdisco::Util::DNS 'hostname_from_ip';

use Storable 'dclone';
use Try::Tiny;

use base 'Exporter';
our @EXPORT = ();
our @EXPORT_OK = qw/
  fixup_device_auth get_external_credentials
/;
our %EXPORT_TAGS = (all => \@EXPORT_OK);

=head1 NAME

App::Netdisco::Util::DeviceAuth

=head1 DESCRIPTION

Helper functions for device authentication.

lib/App/Netdisco/Util/DeviceAuth.pm  view on Meta::CPAN


  foreach my $stanza (@new_stanzas) {
    $stanza->{driver} ||= 'snmp'
      if exists $stanza->{community}
         or exists $stanza->{user};
  }

  return @new_stanzas;
}

=head2 get_external_credentials( $device, $mode )

Runs a command to gather SNMP credentials or a C<device_auth> stanza.

Mode can be C<read> or C<write> and defaults to 'read'.

=cut

sub get_external_credentials {
  my ($device, $mode) = @_;
  my $cmd = (setting('get_credentials') || setting('get_community'));
  my $ip = $device->ip;
  my $host = ($device->dns || hostname_from_ip($ip) || $ip);
  $mode ||= 'read';

  # the command below reaches a shell, and this name comes from DNS, so fall
  # back to the address unless it is shaped like a hostname
  $host = $ip unless $host =~ m/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/;

  if (defined $cmd and length $cmd) {
      # replace variables

lib/App/Netdisco/Util/DeviceAuth.pm  view on Meta::CPAN

      $cmd =~ s/\%HOST\%/$host/egi;
      $cmd =~ s/\%IP\%/$ip/egi;

      my $result = (-r $cmd && ! -x $cmd) ? do { local (@ARGV, $/) = $cmd; <> } : `$cmd`;    # BACKTICKS
      return () unless defined $result and length $result;

      my @lines = split (m/\n/, $result);
      foreach my $line (@lines) {
          if ($line =~ m/^community\s*=\s*(.*)\s*$/i) {
              if (length $1 and $mode eq 'read') {
                  debug sprintf '[%s] external read credentials added',
                    $device->ip;

                  return map {{
                    read => 1,
                    only => [$device->ip],
                    community => $_,
                  }} split(m/\s*,\s*/,$1);
              }
          }
          elsif ($line =~ m/^setCommunity\s*=\s*(.*)\s*$/i) {
              if (length $1 and $mode eq 'write') {
                  debug sprintf '[%s] external write credentials added',
                    $device->ip;

                  return map {{
                    write => 1,
                    only => [$device->ip],
                    community => $_,
                  }} split(m/\s*,\s*/,$1);
              }
          }
          else {
            my $stanza = undef;
            try {
              $stanza = from_json( $line );
              debug sprintf '[%s] external credentials stanza added',
                $device->ip;
            }
            catch {
              info sprintf '[%s] error! failed to parse external credentials stanza',
                $device->ip;
            };
            return $stanza if ref $stanza;
          }
      }
  }

  return ();
}

lib/App/Netdisco/Util/SNMP.pm  view on Meta::CPAN

package App::Netdisco::Util::SNMP;

use Dancer qw/:syntax :script/;
use App::Netdisco::Util::DeviceAuth 'get_external_credentials';

use Path::Class 'dir';
use File::Spec::Functions qw/splitdir catdir catfile/;
use MIME::Base64 'decode_base64';
use SNMP::Info;
use JSON::PP ();

use base 'Exporter';
our @EXPORT = ();
our @EXPORT_OK = qw/

lib/App/Netdisco/Util/SNMP.pm  view on Meta::CPAN


sub get_communities {
  my ($device, $mode) = @_;
  $mode ||= 'read';

  my $seen_tags = {}; # for cleaning community table
  my $config = (setting('device_auth') || []);
  my @communities = ();

  # first of all, use external command if configured
  push @communities, get_external_credentials($device, $mode);

  # last known-good by tag
  my $tag_name = 'snmp_auth_tag_'. $mode;
  my $stored_tag = eval { $device->community->$tag_name };

  if ($device->in_storage and $stored_tag) {
    foreach my $stanza (@$config) {
      if ($stanza->{tag} and $stored_tag eq $stanza->{tag}) {
        push @communities, {%$stanza, only => [$device->ip]};
        ++$seen_tags->{ $stored_tag };

share/config.yml  view on Meta::CPAN

use_legacy_sshcollector: false
custom_fields:
  device: []
  device_port: []
tags:
  device: {}
  device_port: {}
hide_tags:
  device: []
  device_port: []
get_credentials: ""
bulkwalk_off: false
bulkwalk_no: []
bulkwalk_repeaters: 20
nonincreasing: false
snmpver: 3
snmptimeout: 3000000
snmp_fast_connect_timeout: 200000
snmpretries: 2
net_snmp_options: {}
snmp_try_slow_connect: true

share/environments/deployment.yml  view on Meta::CPAN

  name: 'netdisco'
  user: 'changeme'
  pass: 'changeme'
  #host: 'localhost'

# --------------------
# RECOMMENDED SETTINGS
# --------------------

# Device authentication settings
# define snmp communities and ssh credentials here
# ````````````````````````````````````````````````
device_auth:
  - tag: 'default_v2_readonly'
    community: 'public'
    read: true
    write: false
#  - tag: 'default_v2_for_write'
#    community: 'private'
#    read: false
#    write: true

share/public/javascripts/htmx.min.js  view on Meta::CPAN

var htmx=(()=>{const e={parse(t){if(!t)return{};if(t.startsWith("{"))return JSON.parse(t);let r=/(?:"([^"]+)"|'([^']+)'|([^\s,:]+))(?:\s*:\s*(?:"([^"]*)"|'([^']*)'|<((?:[^/]|\/(?!>))+)\/>|([^\s,]+)))?(?=\s|,|$)/g,i={};for(let s of t.matchAll(r)){let[...

share/public/javascripts/netdisco-request.js  view on Meta::CPAN


  /**
   * Posts form-encoded fields to one of netdisco's ajax routes.
   * @param {string} url the route, already carrying uri_base
   * @param {URLSearchParams} body the fields to send
   * @returns {Promise<Response>} resolves for every status, including 4xx and 5xx
   */
  function post(url, body) {
    return fetch(url, {
      method: 'POST',
      credentials: 'same-origin',
      headers: {
        'X-Requested-With': 'XMLHttpRequest',
        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
      },
      body: body
    });
  }

  /**
   * Normalizes a submitted value's line endings to CRLF, which is what a form

share/public/javascripts/netdisco-request.js  view on Meta::CPAN

    return fields(form, 'input, select, textarea').toString();
  }

  /**
   * Fetches from one of netdisco's ajax routes.
   * @param {string} url the route, already carrying uri_base
   * @returns {Promise<Response>} resolves for every status, including 4xx and 5xx
   */
  function get(url) {
    return fetch(url, {
      credentials: 'same-origin',
      headers: { 'X-Requested-With': 'XMLHttpRequest' }
    });
  }

  /**
   * Fetches and parses JSON: a failed response is an error rather than a body
   * to parse.
   * @param {string} url the route, already carrying uri_base
   * @returns {Promise<any>} the parsed body
   */

share/public/swagger-ui/swagger-ui-bundle.js  view on Meta::CPAN

/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */
!function webpackUniversalModuleDefinition(s,o){"object"==typeof exports&&"object"==typeof module?module.exports=o():"function"==typeof define&&define.amd?define([],o):"object"==typeof exports?exports.SwaggerUIBundle=o():s.SwaggerUIBundle=o()}(this,(...

share/public/swagger-ui/swagger-ui-bundle.js.map  view on Meta::CPAN

{"version":3,"file":"swagger-ui-bundle.js","mappings":";CAAA,SAAUA,iCAAiCC,EAAMC,GAC1B,iBAAZC,SAA0C,iBAAXC,OACxCA,OAAOD,QAAUD,IACQ,mBAAXG,QAAyBA,OAAOC,IAC9CD,OAAO,GAAIH,GACe,iBAAZC,QACdA,QAAyB,gBAAID,IAE7BD,EAAsB,gBAAIC,GAC3B,CATD,CASGK,MAAM,cCRLC,EA...

share/python/netdisco/netdisco/util/ssh.py  view on Meta::CPAN

"""
netdisco.util.ssh
~~~~~~~~~~~~~~~~~

This module provides a netmiko connection handler using
the credentials in device_auth.
"""

import os
from netmiko import ConnectHandler

from netdisco.util.config import setting
from netdisco.util.job import job

if 'ND2_FSM_TEMPLATES' in os.environ:
    os.environ['NET_TEXTFSM'] = os.environ['ND2_FSM_TEMPLATES']

xt/91-external-credentials-host.t  view on Meta::CPAN

#!/usr/bin/env perl

# get_credentials is a command template run through a shell, and %HOST% is
# filled from the device's DNS name, which the device's owner does not control.
# A name that is not shaped like a hostname is replaced by the address.
#
# The command below writes a file. Its absence after a run is what proves the
# injected text never reached the shell.

use strict;
use warnings;

BEGIN { $ENV{DANCER_ENVIRONMENT} = 'testing' }

use Test::More 0.88;
use File::Temp ();
use File::Spec::Functions 'catfile';
use App::Netdisco;
use Dancer qw/setting/;
use App::Netdisco::Util::DeviceAuth 'get_external_credentials';

my $dir = File::Temp->newdir();

{ package Test::FakeDevice;
  sub new { my ($c, %a) = @_; return bless {%a}, $c }
  sub ip  { $_[0]->{ip} }
  sub dns { $_[0]->{dns} }
}

subtest 'getExternalCredentials__an_ordinary_name__is_used' => sub {
  setting('get_credentials' => 'echo community=%HOST%');
  my @got = get_external_credentials(
    Test::FakeDevice->new(ip => '192.0.2.10', dns => 'switch.example.com'), 'read');
  is scalar @got, 1, 'one credential returned';
  is $got[0]->{community}, 'switch.example.com', 'the name reached the command';
};

subtest 'getExternalCredentials__a_name_carrying_shell_syntax__never_reaches_the_shell' => sub {
  my $marker = catfile($dir, 'fired');
  setting('get_credentials' => 'echo community=%HOST%');
  my @got = get_external_credentials(
    Test::FakeDevice->new(ip => '192.0.2.10',
      dns => "x.example.com'; touch '$marker"), 'read');

  ok !-e $marker, 'the injected command did not run';
  is $got[0]->{community}, '192.0.2.10', 'the address was used in place of the name';
};

subtest 'getExternalCredentials__a_name_with_a_metacharacter__falls_back_to_the_address' => sub {
  setting('get_credentials' => 'echo community=%HOST%');
  foreach my $dns ('a b', 'a;b', 'a`b`', 'a$(b)', 'a|b', '-leading', 'trailing-') {
    my @got = get_external_credentials(
      Test::FakeDevice->new(ip => '192.0.2.10', dns => $dns), 'read');
    is $got[0]->{community}, '192.0.2.10', "[$dns] fell back to the address";
  }
};

done_testing;

xt/js/request-helper.test.js  view on Meta::CPAN

}

test('post__sending_a_body__carries_the_XMLHttpRequest_header', async () => {
  let seen = null;
  const ndRequest = load((url, init) => { seen = { url, init }; return Promise.resolve({ ok: true, status: 200 }); });
  await ndRequest.post('/ajax/portcontrol', new URLSearchParams({ device: '192.0.2.1' }));
  assert.equal(seen.url, '/ajax/portcontrol');
  assert.equal(seen.init.method, 'POST');
  assert.equal(seen.init.headers['X-Requested-With'], 'XMLHttpRequest');
  assert.match(seen.init.headers['Content-Type'], /application\/x-www-form-urlencoded/);
  assert.equal(seen.init.credentials, 'same-origin');
  assert.equal(String(seen.init.body), 'device=192.0.2.1');
});

test('post__a_server_error__resolves_so_the_caller_can_read_the_status', async () => {
  const ndRequest = load(() => Promise.resolve({ ok: false, status: 500 }));
  const res = await ndRequest.post('/ajax/portcontrol', new URLSearchParams());
  assert.equal(res.status, 500, 'fetch does not reject on 5xx and the helper must not hide that');
});

test('fields__over_a_row_of_inputs__skips_an_unchecked_box_and_a_nameless_field', () => {



( run in 1.532 second using v1.01-cache-2.11-cpan-007c89162af )