view release on metacpan or search on metacpan
* 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
* #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
* 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
* #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
[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
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', () => {